This commit is contained in:
jlimolina 2026-08-28 21:10:02 +02:00
parent 3991237685
commit a01050bf85
46 changed files with 3636 additions and 652 deletions

View file

@ -14,11 +14,11 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/mmcdole/gofeed"
"github.com/rss2/backend/internal/logger"
"github.com/rss2/backend/internal/workers"
)
var (
logger *log.Logger
pool *workers.Config
dbPool *pgxpool.Pool
sleepSec = 900 // 15 minutes
@ -35,7 +35,8 @@ type URLSource struct {
}
func init() {
logger = log.New(os.Stdout, "[DISCOVERY] ", log.LstdFlags)
logLevel := os.Getenv("LOG_LEVEL")
logger.Init("discovery-worker", logLevel)
}
func loadConfig() {
@ -416,48 +417,70 @@ func processURLSource(ctx context.Context, source URLSource) {
func main() {
loadConfig()
logger.Println("Starting RSS Discovery Worker")
logger.Info().Msg("Starting RSS Discovery Worker")
cfg := workers.LoadDBConfig()
if err := workers.Connect(cfg); err != nil {
logger.Fatalf("Failed to connect to database: %v", err)
logger.Fatal().Err(err).Msg("Failed to connect to database")
}
dbPool = workers.GetPool()
defer workers.Close()
logger.Println("Connected to PostgreSQL")
logger.Info().Msg("Connected to PostgreSQL")
ctx := context.Background()
// Start health check HTTP server
go func() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if err := workers.HealthCheck(r.Context()); err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
logger.Info().Msg("Health check server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
logger.Error().Err(err).Msg("Health check server error")
}
}()
ctx, cancel := context.WithCancel(context.Background())
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
logger.Println("Shutting down...")
logger.Info().Msg("Shutting down gracefully...")
cancel()
time.Sleep(2 * time.Second)
workers.Close()
os.Exit(0)
}()
logger.Printf("Config: interval=%ds, batch=%d", sleepSec, batchSize)
logger.Info().Msgf("Config: interval=%ds, batch=%d", sleepSec, batchSize)
ticker := time.NewTicker(time.Duration(sleepSec) * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info().Msg("Context cancelled, stopping worker")
return
case <-ticker.C:
sources, err := getPendingURLs(ctx)
if err != nil {
logger.Printf("Error fetching URLs: %v", err)
logger.Error().Err(err).Msg("Error fetching URLs")
continue
}
if len(sources) == 0 {
logger.Println("No pending URLs to process")
logger.Info().Msg("No pending URLs to process")
continue
}
logger.Printf("Processing %d sources", len(sources))
logger.Info().Msgf("Processing %d sources", len(sources))
for _, source := range sources {
processURLSource(ctx, source)

View file

@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
@ -17,11 +16,11 @@ import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rss2/backend/internal/logger"
"github.com/rss2/backend/internal/workers"
)
var (
logger *log.Logger
dbPool *pgxpool.Pool
qdrantURL string
collection = "news_vectors"
@ -30,28 +29,29 @@ var (
)
func init() {
logger = log.New(os.Stdout, "[QDRANT] ", log.LstdFlags)
logLevel := os.Getenv("LOG_LEVEL")
logger.Init("qdrant-worker", logLevel)
}
func loadConfig() {
sleepSec = getEnvInt("QDRANT_SLEEP", 30)
batchSize = getEnvInt("QDRANT_BATCH", 100)
qdrantHost := getEnv("QDRANT_HOST", "localhost")
qdrantPort := getEnvInt("QDRANT_PORT", 6333)
qdrantURL = fmt.Sprintf("http://%s:%d", qdrantHost, qdrantPort)
collection = getEnv("QDRANT_COLLECTION", "news_vectors")
batchSize = getEnvInt("QDRANT_BATCH",100)
qdrantHost := getEnv("QDRANT_HOST","localhost")
qdrantPort := getEnvInt("QDRANT_PORT",6333)
qdrantURL = fmt.Sprintf("http://%s:%d",qdrantHost,qdrantPort)
collection = getEnv("QDRANT_COLLECTION","news_vectors")
}
func getEnv(key, defaultValue string) string {
func getEnv(key,defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
func getEnvInt(key string,defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intVal, err := strconv.Atoi(value); err == nil {
if intVal,err := strconv.Atoi(value); err == nil {
return intVal
}
}
@ -310,17 +310,33 @@ func main() {
cfg := workers.LoadDBConfig()
if err := workers.Connect(cfg); err != nil {
logger.Fatalf("Failed to connect to database: %v", err)
logger.Fatal().Err(err).Msg("Failed to connect to database")
}
dbPool = workers.GetPool()
defer workers.Close()
logger.Println("Connected to PostgreSQL")
logger.Info().Msg("Connected to PostgreSQL")
ctx := context.Background()
// Start health check HTTP server
go func() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if err := workers.HealthCheck(r.Context()); err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
logger.Info().Msg("Health check server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
logger.Error().Err(err).Msg("Health check server error")
}
}()
ctx, cancel := context.WithCancel(context.Background())
if err := ensureCollection(ctx); err != nil {
logger.Printf("Warning: Could not ensure collection: %v", err)
logger.Warn().Err(err).Msg("Could not ensure collection")
}
sigChan := make(chan os.Signal, 1)
@ -328,30 +344,36 @@ func main() {
go func() {
<-sigChan
logger.Println("Shutting down...")
logger.Info().Msg("Shutting down gracefully...")
cancel()
time.Sleep(2 * time.Second)
workers.Close()
os.Exit(0)
}()
logger.Printf("Config: qdrant=%s, collection=%s, sleep=%ds, batch=%d",
logger.Info().Msgf("Config: qdrant=%s, collection=%s, sleep=%ds, batch=%d",
qdrantURL, collection, sleepSec, batchSize)
totalProcessed := 0
for {
select {
case <-ctx.Done():
logger.Info().Msg("Context cancelled, stopping worker")
return
case <-time.After(time.Duration(sleepSec) * time.Second):
translations, err := getPendingTranslations(ctx)
if err != nil {
logger.Printf("Error fetching pending translations: %v", err)
logger.Error().Err(err).Msg("Error fetching pending translations")
continue
}
if len(translations) == 0 {
logger.Println("No pending translations to process")
logger.Info().Msg("No pending translations to process")
continue
}
logger.Printf("Processing %d translations...", len(translations))
logger.Info().Msgf("Processing %d translations...", len(translations))
// Generate point IDs once (used both in Qdrant and DB)
pointIDs := make([]string, len(translations))
@ -361,21 +383,21 @@ func main() {
// Upload to Qdrant
if err := uploadToQdrant(translations, pointIDs); err != nil {
logger.Printf("Error uploading to Qdrant: %v", err)
logger.Error().Err(err).Msg("Error uploading to Qdrant")
continue
}
// Update DB status
if err := updateTranslationStatus(ctx, translations, pointIDs); err != nil {
logger.Printf("Error updating status: %v", err)
logger.Error().Err(err).Msg("Error updating status")
}
totalProcessed += len(translations)
logger.Printf("Processed %d translations (total: %d)", len(translations), totalProcessed)
logger.Info().Msgf("Processed %d translations (total: %d)", len(translations), totalProcessed)
total, vectorized, pending, err := getStats(ctx)
if err == nil {
logger.Printf("Stats: total=%d, vectorized=%d, pending=%d", total, vectorized, pending)
logger.Info().Msgf("Stats: total=%d, vectorized=%d, pending=%d", total, vectorized, pending)
}
}
}

View file

@ -3,6 +3,7 @@ package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"strconv"
@ -10,11 +11,11 @@ import (
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rss2/backend/internal/logger"
"github.com/rss2/backend/internal/workers"
)
var (
logger *log.Logger
dbPool *pgxpool.Pool
sleepSec = 10
topK = 10
@ -23,7 +24,8 @@ var (
)
func init() {
logger = log.New(os.Stdout, "[RELATED] ", log.LstdFlags)
logLevel := os.Getenv("LOG_LEVEL")
logger.Init("related-worker", logLevel)
}
func loadConfig() {
@ -335,20 +337,36 @@ func processBatch(ctx context.Context, model string) (int, error) {
func main() {
loadConfig()
logger.Println("Starting Related News Worker")
logger.Info().Msg("Starting Related News Worker")
cfg := workers.LoadDBConfig()
if err := workers.Connect(cfg); err != nil {
logger.Fatalf("Failed to connect to database: %v", err)
logger.Fatal().Err(err).Msg("Failed to connect to database")
}
dbPool = workers.GetPool()
defer workers.Close()
ctx := context.Background()
// Start health check HTTP server
go func() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if err := workers.HealthCheck(r.Context()); err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
logger.Info().Msg("Health check server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
logger.Error().Err(err).Msg("Health check server error")
}
}()
ctx, cancel := context.WithCancel(context.Background())
// Ensure schema
if err := ensureSchema(ctx); err != nil {
logger.Printf("Error ensuring schema: %v", err)
logger.Error().Err(err).Msg("Error ensuring schema")
}
model := os.Getenv("EMB_MODEL")
@ -361,23 +379,29 @@ func main() {
go func() {
<-sigChan
logger.Println("Shutting down...")
logger.Info().Msg("Shutting down gracefully...")
cancel()
time.Sleep(2 * time.Second)
workers.Close()
os.Exit(0)
}()
logger.Printf("Config: sleep=%ds, topK=%d, batch=%d, model=%s", sleepSec, topK, batchSz, model)
logger.Info().Msgf("Config: sleep=%ds, topK=%d, batch=%d, model=%s", sleepSec, topK, batchSz, model)
for {
select {
case <-ctx.Done():
logger.Info().Msg("Context cancelled, stopping worker")
return
case <-time.After(time.Duration(sleepSec) * time.Second):
count, err := processBatch(ctx, model)
if err != nil {
logger.Printf("Error processing batch: %v", err)
logger.Error().Err(err).Msg("Error processing batch")
continue
}
if count > 0 {
logger.Printf("Generated related news for %d translations", count)
logger.Info().Msgf("Generated related news for %d translations", count)
}
}
}

View file

@ -5,7 +5,6 @@ import (
"crypto/md5"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
@ -17,12 +16,11 @@ import (
"github.com/PuerkitoBio/goquery"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rss2/backend/internal/logger"
"github.com/rss2/backend/internal/workers"
)
var (
logger *log.Logger
dbPool *workers.Config
pool *pgxpool.Pool
sleepInterval = 60
batchSize = 10
@ -59,15 +57,31 @@ type Article struct {
}
func init() {
logger = log.New(os.Stdout, "[SCRAPER] ", log.LstdFlags)
logger.SetOutput(os.Stdout)
logLevel := os.Getenv("LOG_LEVEL")
logger.Init("scraper-worker", logLevel)
}
func loadConfig() {
sleepInterval = getEnvInt("SCRAPER_SLEEP", 60)
batchSize = getEnvInt("SCRAPER_BATCH", 10)
enrichLimit = getEnvInt("SCRAPER_ENRICH_LIMIT", 20)
scraperWorkers = getEnvInt("SCRAPER_WORKERS", 5)
if v := os.Getenv("SCRAPER_INTERVAL"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i > 0 {
sleepInterval = i
}
}
if v := os.Getenv("SCRAPER_BATCH"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i > 0 {
batchSize = i
}
}
if v := os.Getenv("SCRAPER_ENRICH_LIMIT"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i > 0 {
enrichLimit = i
}
}
if v := os.Getenv("SCRAPER_WORKERS"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i > 0 {
scraperWorkers = i
}
}
}
func getEnvInt(key string, defaultValue int) int {
@ -448,18 +462,34 @@ func scrapeSources(ctx context.Context, sources []URLSource) {
func main() {
loadConfig()
logger.Println("Starting Scraper Worker")
logger.Info().Msg("Starting Scraper Worker")
cfg := workers.LoadDBConfig()
if err := workers.Connect(cfg); err != nil {
logger.Fatalf("Failed to connect to database: %v", err)
logger.Fatal().Err(err).Msg("Failed to connect to database")
}
pool = workers.GetPool()
defer workers.Close()
logger.Println("Connected to PostgreSQL")
logger.Info().Msg("Connected to PostgreSQL")
ctx := context.Background()
// Start health check HTTP server
go func() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if err := workers.HealthCheck(r.Context()); err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
logger.Info().Msg("Health check server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
logger.Error().Err(err).Msg("Health check server error")
}
}()
ctx, cancel := context.WithCancel(context.Background())
// Handle shutdown
sigChan := make(chan os.Signal, 1)
@ -467,38 +497,44 @@ func main() {
go func() {
<-sigChan
logger.Println("Shutting down...")
logger.Info().Msg("Shutting down gracefully...")
cancel()
time.Sleep(2 * time.Second)
workers.Close()
os.Exit(0)
}()
logger.Printf("Config: sleep=%ds, batch=%d, enrich=%d", sleepInterval, batchSize, enrichLimit)
logger.Info().Msgf("Config: sleep=%ds, batch=%d, enrich=%d", sleepInterval, batchSize, enrichLimit)
ticker := time.NewTicker(time.Duration(sleepInterval) * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info().Msg("Context cancelled, stopping worker")
return
case <-ticker.C:
noticias, err := getNoticiasToEnrich(ctx, enrichLimit)
if err != nil {
logger.Printf("Error fetching noticias to enrich: %v", err)
logger.Error().Err(err).Msg("Error fetching noticias to enrich")
} else if len(noticias) > 0 {
logger.Printf("Enriching %d noticias (%d workers)", len(noticias), scraperWorkers)
logger.Info().Msgf("Enriching %d noticias (%d workers)", len(noticias), scraperWorkers)
enrichConcurrent(ctx, noticias)
}
sources, err := getActiveURLs(ctx)
if err != nil {
logger.Printf("Error fetching URLs: %v", err)
logger.Error().Err(err).Msg("Error fetching URLs")
continue
}
if len(sources) == 0 {
logger.Println("No active URLs to process")
logger.Info().Msg("No active URLs to process")
continue
}
logger.Printf("Processing %d sources (%d workers)", len(sources), scraperWorkers)
logger.Info().Msgf("Processing %d sources (%d workers)", len(sources), scraperWorkers)
scrapeSources(ctx, sources)
}
}

View file

@ -3,7 +3,6 @@ package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
@ -14,12 +13,17 @@ import (
"github.com/rss2/backend/internal/config"
"github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/handlers"
"github.com/rss2/backend/internal/logger"
"github.com/rss2/backend/internal/middleware"
"github.com/rss2/backend/internal/services"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
_ "github.com/rss2/backend/docs"
)
func initDB() {
ctx := context.Background()
log := logger.GetLogger()
// Crear tabla entity_aliases si no existe
_, err := db.GetPool().Exec(ctx, `
@ -33,9 +37,9 @@ func initDB() {
)
`)
if err != nil {
log.Printf("Warning: Could not create entity_aliases table: %v", err)
log.Warn().Err(err).Msg("Could not create entity_aliases table")
} else {
log.Println("Table entity_aliases ready")
log.Info().Msg("Table entity_aliases ready")
}
// Añadir columna role a users si no existe
@ -43,9 +47,9 @@ func initDB() {
ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) DEFAULT 'user'
`)
if err != nil {
log.Printf("Warning: Could not add role column: %v", err)
log.Warn().Err(err).Msg("Could not add role column")
} else {
log.Println("Column role ready")
log.Info().Msg("Column role ready")
}
// Crear tabla de configuración si no existe
@ -129,31 +133,66 @@ func initDB() {
if err != nil {
log.Printf("Warning: Could not add assigned_at column: %v", err)
}
// Crear índices para optimizar consultas críticas
indexes := []string{
"CREATE INDEX IF NOT EXISTS idx_noticias_fecha ON noticias(fecha DESC)",
"CREATE INDEX IF NOT EXISTS idx_noticias_fuente_nombre ON noticias(fuente_nombre)",
"CREATE INDEX IF NOT EXISTS idx_noticias_categoria_id ON noticias(categoria_id)",
"CREATE INDEX IF NOT EXISTS idx_noticias_pais_id ON noticias(pais_id)",
"CREATE INDEX IF NOT EXISTS idx_noticias_lang ON noticias(lang)",
"CREATE INDEX IF NOT EXISTS idx_traducciones_noticia_id_lang_to ON traducciones(noticia_id, lang_to)",
"CREATE INDEX IF NOT EXISTS idx_traducciones_status ON traducciones(status)",
"CREATE INDEX IF NOT EXISTS idx_tags_noticia_noticia_id_tag_id ON tags_noticia(noticia_id, tag_id)",
"CREATE INDEX IF NOT EXISTS idx_tags_noticia_traduccion_id ON tags_noticia(traduccion_id)",
"CREATE INDEX IF NOT EXISTS idx_tags_valor_tipo ON tags(valor, tipo)",
"CREATE INDEX IF NOT EXISTS idx_entity_aliases_alias_tipo ON entity_aliases(alias, tipo)",
"CREATE INDEX IF NOT EXISTS idx_alertas_valor_tipo_periodo ON alertas(valor, tipo, periodo)",
"CREATE INDEX IF NOT EXISTS idx_alertas_status ON alertas(status)",
"CREATE INDEX IF NOT EXISTS idx_user_search_tags_user_id ON user_search_tags(user_id)",
"CREATE INDEX IF NOT EXISTS idx_feeds_activo ON feeds(activo)",
"CREATE INDEX IF NOT EXISTS idx_feeds_categoria_id ON feeds(categoria_id)",
"CREATE INDEX IF NOT EXISTS idx_feeds_pais_id ON feeds(pais_id)",
}
for _, idx := range indexes {
_, err := db.GetPool().Exec(ctx, idx)
if err != nil {
log.Warn().Err(err).Msg("Could not create index")
} else {
log.Info().Msg("Index created/verified")
}
}
}
func main() {
cfg := config.Load()
// Initialize structured logger
logLevel := os.Getenv("LOG_LEVEL")
logger.Init("rss2-api", logLevel)
log := logger.GetLogger()
if err := db.Connect(cfg.DatabaseURL); err != nil {
log.Fatalf("Failed to connect to database: %v", err)
log.Fatal().Err(err).Msg("Failed to connect to database")
}
defer db.Close()
log.Println("Connected to PostgreSQL")
log.Info().Msg("Connected to PostgreSQL")
// Auto-setup DB tables
initDB()
if err := cache.Connect(cfg.RedisURL); err != nil {
log.Printf("Warning: Failed to connect to Redis: %v", err)
log.Warn().Err(err).Msg("Failed to connect to Redis")
} else {
defer cache.Close()
log.Println("Connected to Redis")
log.Info().Msg("Connected to Redis")
}
services.Init(cfg)
r := gin.Default()
r.Use(middleware.RequestIDMiddleware())
r.Use(middleware.CORSMiddleware())
r.Use(middleware.LoggerMiddleware())
@ -161,14 +200,25 @@ func main() {
c.JSON(200, gin.H{"status": "ok"})
})
// Swagger documentation
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
api := r.Group("/api")
{
// Serve static images downloaded by wiki_worker
api.StaticFS("/wiki-images", gin.Dir(cfg.WikiImagesPath, false))
api.POST("/auth/login", handlers.Login)
api.POST("/auth/register", handlers.Register)
api.GET("/auth/check-first-user", handlers.CheckFirstUser)
// Stricter rate limiting for auth endpoints
authGroup := api.Group("/auth")
authGroup.Use(middleware.RateLimitMiddleware(10)) // 10 req/min for auth
{
authGroup.POST("/login", handlers.Login)
authGroup.POST("/register", handlers.Register)
authGroup.GET("/check-first-user", handlers.CheckFirstUser)
}
// General rate limiting for API
api.Use(middleware.RateLimitMiddleware(cfg.RateLimitPerMinute))
news := api.Group("/news")
{
@ -251,9 +301,9 @@ func main() {
addr := fmt.Sprintf(":%s", port)
go func() {
log.Printf("Server starting on %s", addr)
log.Info().Str("addr", addr).Msg("Server starting")
if err := r.Run(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
log.Fatal().Err(err).Msg("Failed to start server")
}
}()
@ -264,5 +314,5 @@ func main() {
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
log.Info().Msg("Shutting down server...")
}

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
@ -12,11 +13,11 @@ import (
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rss2/backend/internal/logger"
"github.com/rss2/backend/internal/workers"
)
var (
logger *log.Logger
dbPool *pgxpool.Pool
sleepSec = 10
batchSz = 500
@ -35,7 +36,8 @@ type Country struct {
}
func init() {
logger = log.New(os.Stdout, "[TOPICS] ", log.LstdFlags)
logLevel := os.Getenv("LOG_LEVEL")
logger.Init("topics-worker", logLevel)
}
func loadConfig() {
@ -335,20 +337,36 @@ func processBatch(ctx context.Context, topics []Topic, countries []Country) (int
func main() {
loadConfig()
logger.Println("Starting Topics Worker")
logger.Info().Msg("Starting Topics Worker")
cfg := workers.LoadDBConfig()
if err := workers.Connect(cfg); err != nil {
logger.Fatalf("Failed to connect to database: %v", err)
logger.Fatal().Err(err).Msg("Failed to connect to database")
}
dbPool = workers.GetPool()
defer workers.Close()
ctx := context.Background()
// Start health check HTTP server
go func() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if err := workers.HealthCheck(r.Context()); err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
logger.Info().Msg("Health check server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
logger.Error().Err(err).Msg("Health check server error")
}
}()
ctx, cancel := context.WithCancel(context.Background())
// Ensure schema
if err := ensureSchema(ctx); err != nil {
logger.Printf("Error ensuring schema: %v", err)
logger.Error().Err(err).Msg("Error ensuring schema")
}
sigChan := make(chan os.Signal, 1)
@ -356,41 +374,47 @@ func main() {
go func() {
<-sigChan
logger.Println("Shutting down...")
logger.Info().Msg("Shutting down gracefully...")
cancel()
time.Sleep(2 * time.Second)
workers.Close()
os.Exit(0)
}()
logger.Printf("Config: sleep=%ds, batch=%d", sleepSec, batchSz)
logger.Info().Msgf("Config: sleep=%ds, batch=%d", sleepSec, batchSz)
for {
select {
case <-ctx.Done():
logger.Info().Msg("Context cancelled, stopping worker")
return
case <-time.After(time.Duration(sleepSec) * time.Second):
topics, err := loadTopics(ctx)
if err != nil {
logger.Printf("Error loading topics: %v", err)
logger.Error().Err(err).Msg("Error loading topics")
continue
}
if len(topics) == 0 {
logger.Println("No topics found in DB")
logger.Info().Msg("No topics found in DB")
time.Sleep(time.Duration(sleepSec) * time.Second)
continue
}
countries, err := loadCountries(ctx)
if err != nil {
logger.Printf("Error loading countries: %v", err)
logger.Error().Err(err).Msg("Error loading countries")
continue
}
count, err := processBatch(ctx, topics, countries)
if err != nil {
logger.Printf("Error processing batch: %v", err)
logger.Error().Err(err).Msg("Error processing batch")
continue
}
if count > 0 {
logger.Printf("Processed %d news items", count)
logger.Info().Msgf("Processed %d news items", count)
}
if count < batchSz {

View file

@ -16,11 +16,11 @@ import (
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rss2/backend/internal/logger"
"github.com/rss2/backend/internal/workers"
)
var (
logger *log.Logger
pool *pgxpool.Pool
sleepInterval = 30
batchSize = 20
@ -52,7 +52,8 @@ type Tag struct {
}
func init() {
logger = log.New(os.Stdout, "[WIKI_WORKER] ", log.LstdFlags)
logLevel := os.Getenv("LOG_LEVEL")
logger.Init("wiki-worker", logLevel)
}
func getPendingTags(ctx context.Context) ([]Tag, error) {
@ -227,52 +228,89 @@ func main() {
}
}
logger.Println("Iniciando Wiki Worker...")
logger.Info().Msg("Iniciando Wiki Worker...")
if err := os.MkdirAll(imagesDir, 0755); err != nil {
logger.Fatalf("Error creando directorio de imágenes: %v", err)
logger.Fatal().Err(err).Msg("Error creando directorio de imágenes")
}
cfg := workers.LoadDBConfig()
if err := workers.Connect(cfg); err != nil {
logger.Fatalf("Failed to connect to database: %v", err)
logger.Fatal().Err(err).Msg("Failed to connect to database")
}
pool = workers.GetPool()
defer workers.Close()
ctx := context.Background()
// Start health check HTTP server
go func() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if err := workers.HealthCheck(r.Context()); err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
logger.Info().Msg("Health check server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
logger.Error().Err(err).Msg("Health check server error")
}
}()
ctx, cancel := context.WithCancel(context.Background())
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
logger.Println("Cerrando gracefully...")
logger.Info().Msg("Shutting down gracefully...")
cancel()
// Give time for in-flight requests to complete
time.Sleep(2 * time.Second)
workers.Close()
os.Exit(0)
}()
logger.Printf("Configuración: sleep=%ds, batch=%d", sleepInterval, batchSize)
logger.Info().Msgf("Configuración: sleep=%ds, batch=%d", sleepInterval, batchSize)
for {
tags, err := getPendingTags(ctx)
if err != nil {
logger.Printf("Error recuperando tags pendientes: %v", err)
time.Sleep(10 * time.Second)
continue
}
select {
case <-ctx.Done():
logger.Info().Msg("Context cancelled, stopping worker")
return
default:
tags, err := getPendingTags(ctx)
if err != nil {
logger.Error().Err(err).Msg("Error recuperando tags pendientes")
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
}
continue
}
if len(tags) == 0 {
logger.Printf("No hay tags pendientes. Durmiendo %d segundos...", sleepInterval)
time.Sleep(time.Duration(sleepInterval) * time.Second)
continue
}
if len(tags) == 0 {
logger.Info().Msgf("No hay tags pendientes. Durmiendo %d segundos...", sleepInterval)
select {
case <-ctx.Done():
return
case <-time.After(time.Duration(sleepInterval) * time.Second):
}
continue
}
logger.Printf("Recuperados %d tags para procesar...", len(tags))
logger.Info().Msgf("Recuperados %d tags para procesar...", len(tags))
for _, tag := range tags {
processTag(ctx, tag)
time.Sleep(3 * time.Second) // Increased delay to avoid Wikipedia Rate Limits (429)
for _, tag := range tags {
processTag(ctx, tag)
select {
case <-ctx.Done():
return
case <-time.After(3 * time.Second): // Increased delay to avoid Wikipedia Rate Limits (429)
}
}
}
}
}

43
backend/docs/docs.go Normal file
View file

@ -0,0 +1,43 @@
// Package docs provides Swagger documentation for the RSS2 API
//
// @title RSS2 API
// @version 1.0
// @description RSS2 News Aggregator API - A comprehensive news aggregation and analysis platform
// @termsOfService http://swagger.io/terms/
//
// @contact.name RSS2 Team
// @contact.url http://github.com/rss2
// @contact.email support@rss2.example.com
//
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
//
// @host localhost:8080
// @BasePath /api
//
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Type "Bearer" followed by a space and JWT token.
//
// @tag.name auth
// @tag.description Authentication endpoints
//
// @tag.name news
// @tag.description News management endpoints
//
// @tag.name feeds
// @tag.description Feed management endpoints
//
// @tag.name search
// @tag.description Search and discovery endpoints
//
// @tag.name entities
// @tag.description Entity extraction and analysis
//
// @tag.name admin
// @tag.description Admin management endpoints
//
// @tag.name stats
// @tag.description Statistics and analytics
package docs

View file

@ -13,10 +13,17 @@ require (
github.com/jackc/pgx/v5 v5.4.3
github.com/mmcdole/gofeed v1.2.1
github.com/redis/go-redis/v9 v9.0.5
golang.org/x/crypto v0.26.0
github.com/rs/zerolog v1.32.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
golang.org/x/crypto v0.28.0
golang.org/x/time v0.5.0
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@ -24,31 +31,39 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // 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.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.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.4 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mmcdole/goxpp v1.1.0 // 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.0.8 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/swaggo/swag v1.16.3 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.17.0 // indirect
golang.org/x/text v0.19.0 // indirect
golang.org/x/tools v0.26.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -1,5 +1,11 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
@ -14,6 +20,7 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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=
@ -22,10 +29,22 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
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.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
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=
@ -36,6 +55,7 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@ -53,17 +73,29 @@ github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
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.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mmcdole/gofeed v1.2.1 h1:tPbFN+mfOLcM1kDF1x2c/N68ChbdBatkppdzf/vDe1s=
@ -75,18 +107,24 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
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/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o=
github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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=
@ -94,6 +132,12 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=
github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk=
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.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
@ -104,17 +148,21 @@ golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -122,13 +170,16 @@ golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@ -137,22 +188,33 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/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=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View file

@ -1,6 +1,7 @@
package auth
import (
"context"
"time"
"github.com/golang-jwt/jwt/v5"
@ -53,3 +54,16 @@ func ValidateToken(tokenString string) (*Claims, error) {
return claims, nil
}
type contextKey string
const userContextKey contextKey = "user"
func SetTestUser(ctx context.Context, claims *Claims) context.Context {
return context.WithValue(ctx, userContextKey, claims)
}
func GetUserFromContext(ctx context.Context) (*Claims, bool) {
claims, ok := ctx.Value(userContextKey).(*Claims)
return claims, ok
}

View file

@ -11,6 +11,26 @@ import (
var Client *redis.Client
// Cache TTL constants
const (
TTLShort = 5 * time.Minute // 5 min for frequently changing data
TTLMedium = 30 * time.Minute // 30 min for semi-static data
TTLLong = 2 * time.Hour // 2 hours for stable data
TTLNoExpire = 0 // No expiration (manual invalidation)
)
const (
KeyPrefixSearch = "search"
KeyPrefixNews = "news"
KeyPrefixFeed = "feed"
KeyPrefixFeedList = "feedlist"
KeyPrefixCategories = "categories"
KeyPrefixCountries = "countries"
KeyPrefixStats = "stats"
KeyPrefixEntities = "entities"
KeyPrefixStatsTop = "statstop"
)
func Connect(redisURL string) error {
opt, err := redis.ParseURL(redisURL)
if err != nil {
@ -40,15 +60,39 @@ func GetClient() *redis.Client {
}
func SearchKey(query, lang string, page, perPage int) string {
return fmt.Sprintf("search:%s:%s:%d:%d", query, lang, page, perPage)
return fmt.Sprintf("%s:%s:%s:%d:%d", KeyPrefixSearch, query, lang, page, perPage)
}
func NewsKey(newsID int64, lang string) string {
return fmt.Sprintf("news:%d:%s", newsID, lang)
return fmt.Sprintf("%s:%d:%s", KeyPrefixNews, newsID, lang)
}
func FeedKey(feedID int64) string {
return fmt.Sprintf("feed:%d", feedID)
return fmt.Sprintf("%s:%d", KeyPrefixFeed, feedID)
}
func FeedListKey(filters string, page, perPage int) string {
return fmt.Sprintf("%s:%s:%d:%d", KeyPrefixFeedList, filters, page, perPage)
}
func CategoriesKey() string {
return KeyPrefixCategories
}
func CountriesKey() string {
return KeyPrefixCountries
}
func StatsKey() string {
return KeyPrefixStats
}
func StatsTopKey(typ string) string {
return fmt.Sprintf("%s:%s", KeyPrefixStatsTop, typ)
}
func EntityKey(entityType, query string, page, perPage int) string {
return fmt.Sprintf("%s:%s:%s:%d:%d", KeyPrefixEntities, entityType, query, page, perPage)
}
func Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
@ -63,6 +107,62 @@ func Get(ctx context.Context, key string) (string, error) {
return Client.Get(ctx, key).Result()
}
func Delete(ctx context.Context, keys ...string) error {
if len(keys) == 0 {
return nil
}
return Client.Del(ctx, keys...).Err()
}
func DeletePattern(ctx context.Context, pattern string) error {
iter := Client.Scan(ctx, 0, pattern, 100).Iterator()
for iter.Next(ctx) {
if err := Client.Del(ctx, iter.Val()).Err(); err != nil {
return err
}
}
return iter.Err()
}
func InvalidateSearch(ctx context.Context) error {
return DeletePattern(ctx, KeyPrefixSearch+":*")
}
func InvalidateNews(ctx context.Context, newsID int64) error {
pattern := fmt.Sprintf("%s:%d:*", KeyPrefixNews, newsID)
return DeletePattern(ctx, pattern)
}
func InvalidateFeed(ctx context.Context, feedID int64) error {
return Delete(ctx, FeedKey(feedID))
}
func InvalidateFeedList(ctx context.Context) error {
return DeletePattern(ctx, KeyPrefixFeedList+":*")
}
func InvalidateCategories(ctx context.Context) error {
return Delete(ctx, CategoriesKey())
}
func InvalidateCountries(ctx context.Context) error {
return Delete(ctx, CountriesKey())
}
func InvalidateStats(ctx context.Context) error {
keys := []string{
StatsKey(),
StatsTopKey("categories"),
StatsTopKey("countries"),
}
return Delete(ctx, keys...)
}
func InvalidateEntities(ctx context.Context, entityType string) error {
pattern := fmt.Sprintf("%s:%s:*", KeyPrefixEntities, entityType)
return DeletePattern(ctx, pattern)
}
func Unmarshal(data []byte, v interface{}) error {
return json.Unmarshal(data, v)
}
@ -70,3 +170,8 @@ func Unmarshal(data []byte, v interface{}) error {
func Marshal(v interface{}) ([]byte, error) {
return json.Marshal(v)
}
func Exists(ctx context.Context, key string) (bool, error) {
count, err := Client.Exists(ctx, key).Result()
return count > 0, err
}

View file

@ -28,7 +28,12 @@ type Config struct {
func Load() *Config {
secretKey := os.Getenv("SECRET_KEY")
if secretKey == "" {
secretKey = "change-this-secret-key"
panic("SECRET_KEY environment variable is required")
}
allowedOrigins := os.Getenv("ALLOWED_ORIGINS")
if allowedOrigins == "" {
allowedOrigins = "http://localhost:5173,http://localhost:3000"
}
return &Config{
@ -47,7 +52,7 @@ func Load() *Config {
RateLimitPerMinute: getEnvInt("RATE_LIMIT_PER_MINUTE", 60),
DockerComposeDir: getEnv("DOCKER_COMPOSE_DIR", "/datos/rss2"),
WikiImagesPath: getEnv("WIKI_IMAGES_PATH", "/app/data/wiki_images"),
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "*"),
AllowedOrigins: allowedOrigins,
}
}

View file

@ -3,6 +3,8 @@ package db
import (
"context"
"fmt"
"os"
"strconv"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@ -16,10 +18,11 @@ func Connect(databaseURL string) error {
return fmt.Errorf("failed to parse database URL: %w", err)
}
config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = time.Hour
config.MaxConnIdleTime = 30 * time.Minute
config.MaxConns = int32(getEnvInt("DB_MAX_CONNS", 25))
config.MinConns = int32(getEnvInt("DB_MIN_CONNS", 5))
config.MaxConnLifetime = getEnvDuration("DB_MAX_CONN_LIFETIME", time.Hour)
config.MaxConnIdleTime = getEnvDuration("DB_MAX_CONN_IDLE_TIME", 30*time.Minute)
config.HealthCheckPeriod = getEnvDuration("DB_HEALTH_CHECK_PERIOD", time.Minute)
Pool, err = pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
@ -42,3 +45,28 @@ func Close() {
func GetPool() *pgxpool.Pool {
return Pool
}
func HealthCheck(ctx context.Context) error {
if Pool == nil {
return fmt.Errorf("pool not initialized")
}
return Pool.Ping(ctx)
}
func getEnvInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intVal, err := strconv.Atoi(value); err == nil {
return intVal
}
}
return defaultValue
}
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
if value := os.Getenv(key); value != "" {
if duration, err := time.ParseDuration(value); err == nil {
return duration
}
}
return defaultValue
}

View file

@ -738,7 +738,23 @@ func resolvePgConnInfo() pgConnInfo {
return info
}
// BackupDatabase runs pg_dump and returns the SQL as a downloadable file
// flushWriter flushes the underlying response writer after every write so that
// large backups stream to the client instead of being buffered in RAM.
type flushWriter struct {
w io.Writer
}
func (fw flushWriter) Write(p []byte) (int, error) {
n, err := fw.w.Write(p)
if f, ok := fw.w.(http.Flusher); ok {
f.Flush()
}
return n, err
}
// BackupDatabase runs pg_dump and returns the SQL as a downloadable file.
// The output is streamed to the client so arbitrarily large dumps do not
// exhaust the container memory.
func BackupDatabase(c *gin.Context) {
info := resolvePgConnInfo()
@ -752,12 +768,12 @@ func BackupDatabase(c *gin.Context) {
)
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
var out bytes.Buffer
pr, pw := io.Pipe()
cmd.Stdout = pw
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if err := cmd.Start(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "pg_dump failed",
"details": stderr.String(),
@ -769,10 +785,29 @@ func BackupDatabase(c *gin.Context) {
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "application/octet-stream", out.Bytes())
c.Status(http.StatusOK)
fw := flushWriter{w: c.Writer}
copyDone := make(chan struct{})
go func() {
defer close(copyDone)
_, _ = io.Copy(fw, pr)
}()
err := cmd.Wait()
pw.Close()
<-copyDone
if err != nil {
log.Printf("BackupDatabase: pg_dump failed: %v: %s", err, stderr.String())
c.Writer.Flush()
return
}
c.Writer.Flush()
}
// BackupNewsZipped performs a pg_dump of news tables and returns a ZIP file
// BackupNewsZipped performs a pg_dump of news tables and returns a ZIP file.
// pg_dump output is streamed into the ZIP as it is produced, keeping memory usage flat.
func BackupNewsZipped(c *gin.Context) {
info := resolvePgConnInfo()
@ -794,12 +829,12 @@ func BackupNewsZipped(c *gin.Context) {
cmd := exec.Command("pg_dump", args...)
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
var sqlOut bytes.Buffer
pr, pw := io.Pipe()
cmd.Stdout = pw
var stderr bytes.Buffer
cmd.Stdout = &sqlOut
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if err := cmd.Start(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "pg_dump failed",
"details": stderr.String(),
@ -807,33 +842,40 @@ func BackupNewsZipped(c *gin.Context) {
return
}
// Create ZIP
buf := new(bytes.Buffer)
zw := zip.NewWriter(buf)
sqlFileName := fmt.Sprintf("backup_noticias_%s.sql", time.Now().Format("2006-01-02"))
f, err := zw.Create(sqlFileName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create ZIP entry", "message": err.Error()})
return
}
_, err = f.Write(sqlOut.Bytes())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write to ZIP", "message": err.Error()})
return
}
if err := zw.Close(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to close ZIP writer", "message": err.Error()})
return
}
filename := fmt.Sprintf("backup_noticias_%s.zip", time.Now().Format("2006-01-02_15-04-05"))
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "application/zip", buf.Bytes())
c.Status(http.StatusOK)
fw := flushWriter{w: c.Writer}
zw := zip.NewWriter(fw)
sqlFileName := fmt.Sprintf("backup_noticias_%s.sql", time.Now().Format("2006-01-02"))
f, err := zw.Create(sqlFileName)
if err != nil {
log.Printf("BackupNewsZipped: failed to create ZIP entry: %v", err)
pw.Close()
pr.Close()
return
}
copyDone := make(chan struct{})
go func() {
defer close(copyDone)
_, _ = io.Copy(f, pr)
}()
waitErr := cmd.Wait()
pw.Close()
<-copyDone
closeErr := zw.Close()
c.Writer.Flush()
if waitErr != nil || closeErr != nil {
log.Printf("BackupNewsZipped: pg_dump=%v zip=%v: %s", waitErr, closeErr, stderr.String())
return
}
}
// RestoreDatabase accepts an uploaded .sql or .zip backup and restores it via psql

View file

@ -109,10 +109,18 @@ func ScanAlertasAdmin(c *gin.Context) {
}
// RunAlertScan busca conceptos (persona, lugar, organizacion, tema) cuya
// actividad de hoy supera significativamente su media de los últimos días.
// actividad supera significativamente su media de los días anteriores.
//
// En lugar de comparar siempre contra CURRENT_DATE (que se queda vacío si la
// cadena de traducción/NER va por detrás de la ingesta), se toma como día de
// referencia el día más reciente que tenga datos de tags etiquetados y se
// compara contra la media de los ALERTS_LOOKBACK_DAYS días previos, contando
// los días sin actividad como 0.
func RunAlertScan(ctx context.Context) (int, error) {
minHits := 3
minHits := 5
minRatio := 5.0
minBaseline := 2.0
lookbackDays := 8
if v := os.Getenv("ALERTS_MIN_HITS"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
minHits = n
@ -123,6 +131,16 @@ func RunAlertScan(ctx context.Context) (int, error) {
minRatio = f
}
}
if v := os.Getenv("ALERTS_MIN_BASELINE"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
minBaseline = f
}
}
if v := os.Getenv("ALERTS_LOOKBACK_DAYS"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
lookbackDays = n
}
}
query := `
WITH daily AS (
@ -132,34 +150,43 @@ func RunAlertScan(ctx context.Context) (int, error) {
JOIN tags t ON tn.tag_id = t.id
JOIN traducciones tr ON tn.traduccion_id = tr.id
JOIN noticias n ON tr.noticia_id = n.id
WHERE n.fecha >= CURRENT_DATE - 8
WHERE n.fecha >= CURRENT_DATE - 30
AND n.fecha <= CURRENT_DATE
AND t.tipo IN ('persona', 'lugar', 'organizacion', 'tema')
GROUP BY t.id, t.valor, t.tipo, n.fecha::date
),
baseline AS (
SELECT tag_id, valor, tipo,
AVG(cnt)::float AS baseline,
MAX(cnt)::int AS max_prev
FROM daily
WHERE dia < CURRENT_DATE
GROUP BY tag_id, valor, tipo
ref AS (
SELECT MAX(dia) AS ref_date FROM daily
),
prev_series AS (
SELECT generate_series((SELECT ref_date - $3::int FROM ref),
(SELECT ref_date - 1 FROM ref), '1 day')::date AS dia
),
today AS (
SELECT tag_id, valor, tipo, SUM(cnt)::int AS hits
FROM daily
WHERE dia = CURRENT_DATE
WHERE dia = (SELECT ref_date FROM ref)
GROUP BY tag_id, valor, tipo
),
base AS (
SELECT t.tag_id, t.valor, t.tipo,
AVG(COALESCE(d.cnt, 0))::float AS baseline
FROM today t
CROSS JOIN prev_series s
LEFT JOIN daily d ON d.tag_id = t.tag_id AND d.dia = s.dia
GROUP BY t.tag_id, t.valor, t.tipo
)
SELECT t.valor, t.tipo, t.hits, b.baseline,
(t.hits::float / NULLIF(b.baseline, 0)) AS ratio
(t.hits::float / b.baseline) AS ratio,
(SELECT ref_date FROM ref)::date AS periodo
FROM today t
JOIN baseline b USING (tag_id)
JOIN base b USING (tag_id)
WHERE t.hits >= $1
AND b.baseline >= 0.5
AND (t.hits::float / NULLIF(b.baseline, 0)) >= $2
AND b.baseline >= $4
AND (t.hits::float / b.baseline) >= $2
`
rows, err := db.GetPool().Query(ctx, query, minHits, minRatio)
rows, err := db.GetPool().Query(ctx, query, minHits, minRatio, lookbackDays, minBaseline)
if err != nil {
return 0, err
}
@ -170,14 +197,15 @@ func RunAlertScan(ctx context.Context) (int, error) {
var valor, tipo string
var hits int
var baseline, ratio float64
if err := rows.Scan(&valor, &tipo, &hits, &baseline, &ratio); err != nil {
var periodo time.Time
if err := rows.Scan(&valor, &tipo, &hits, &baseline, &ratio, &periodo); err != nil {
continue
}
res, err := db.GetPool().Exec(ctx, `
INSERT INTO alertas (valor, tipo, periodo, hits, baseline, ratio)
VALUES ($1, $2, CURRENT_DATE, $3, $4, $5)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (valor, tipo, periodo) DO NOTHING
`, valor, tipo, hits, baseline, ratio)
`, valor, tipo, periodo, hits, baseline, ratio)
if err != nil {
log.Printf("alerts: insert failed for %s (%s): %v", valor, tipo, err)
continue
@ -200,12 +228,12 @@ func StartAlertScanner() {
}
}
go func() {
time.Sleep(45 * time.Second)
for {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
inserted, err := RunAlertScan(ctx)
cancel()
go func() {
time.Sleep(45 * time.Second)
for {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
inserted, err := RunAlertScan(ctx)
cancel()
if err != nil {
log.Printf("alerts: scan error: %v", err)
} else {

View file

@ -10,6 +10,14 @@ import (
"golang.org/x/crypto/bcrypt"
)
// CheckFirstUser checks if this is the first user registration
// @Summary Check if first user
// @Description Returns whether this is the first user being registered (no users exist yet)
// @Tags auth
// @Produce json
// @Success 200 {object} map[string]interface{} "is_first_user and total_users"
// @Failure 500 {object} models.ErrorResponse
// @Router /auth/check-first-user [get]
func CheckFirstUser(c *gin.Context) {
var count int
err := db.GetPool().QueryRow(c.Request.Context(), "SELECT COUNT(*) FROM users").Scan(&count)
@ -20,6 +28,18 @@ func CheckFirstUser(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"is_first_user": count == 0, "total_users": count})
}
// Login authenticates a user and returns a JWT token
// @Summary User login
// @Description Authenticate with email and password to receive a JWT token
// @Tags auth
// @Accept json
// @Produce json
// @Param request body models.LoginRequest true "Login credentials"
// @Success 200 {object} models.AuthResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
// @Router /auth/login [post]
func Login(c *gin.Context) {
var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -56,6 +76,17 @@ func Login(c *gin.Context) {
})
}
// Register creates a new user account
// @Summary Register new user
// @Description Create a new user account (first user becomes admin automatically)
// @Tags auth
// @Accept json
// @Produce json
// @Param request body models.RegisterRequest true "Registration details"
// @Success 201 {object} models.AuthResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
// @Router /auth/register [post]
func Register(c *gin.Context) {
var req models.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -111,6 +142,16 @@ func Register(c *gin.Context) {
})
}
// GetCurrentUser returns the current authenticated user
// @Summary Get current user
// @Description Returns the currently authenticated user's profile
// @Tags auth
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.User
// @Failure 401 {object} models.ErrorResponse
// @Failure 404 {object} models.ErrorResponse
// @Router /auth/me [get]
func GetCurrentUser(c *gin.Context) {
userVal, exists := c.Get("user")
if !exists {

View file

@ -17,6 +17,7 @@ func init() {
auth.SetJWTSecret("test-secret-key-12345")
}
// TestLoginInvalidRequest tests that an empty request body returns 400
func TestLoginInvalidRequest(t *testing.T) {
router := gin.New()
router.POST("/auth/login", Login)
@ -40,22 +41,7 @@ func TestLoginInvalidRequest(t *testing.T) {
}
}
func TestLoginInvalidCredentials(t *testing.T) {
router := gin.New()
router.POST("/auth/login", Login)
body := []byte(`{"email":"invalid@test.com","password":"wrongpass"}`)
req, _ := http.NewRequest("POST", "/auth/login", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d", w.Code)
}
}
// TestRegisterInvalidRequest tests that an empty request body returns 400
func TestRegisterInvalidRequest(t *testing.T) {
router := gin.New()
router.POST("/auth/register", Register)
@ -72,27 +58,7 @@ func TestRegisterInvalidRequest(t *testing.T) {
}
}
func TestCheckFirstUser(t *testing.T) {
router := gin.New()
router.GET("/auth/check-first-user", CheckFirstUser)
req, _ := http.NewRequest("GET", "/auth/check-first-user", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if _, ok := resp["is_first_user"]; !ok {
t.Error("expected is_first_user in response")
}
}
// TestGetCurrentUserUnauthorized tests that unauthenticated request returns 401
func TestGetCurrentUserUnauthorized(t *testing.T) {
router := gin.New()
router.GET("/auth/me", GetCurrentUser)

View file

@ -3,6 +3,7 @@ package handlers
import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"net/http"
@ -11,40 +12,46 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/cache"
"github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/models"
)
type FeedResponse struct {
ID int64 `json:"id"`
Nombre string `json:"nombre"`
Descripcion *string `json:"descripcion"`
URL string `json:"url"`
CategoriaID *int64 `json:"categoria_id"`
PaisID *int64 `json:"pais_id"`
Idioma *string `json:"idioma"`
Activo bool `json:"activo"`
Fallos *int64 `json:"fallos"`
LastError *string `json:"last_error"`
FuenteURLID *int64 `json:"fuente_url_id"`
}
// GetFeeds returns paginated feeds with optional filters
// @Summary List feeds
// @Description Returns paginated feeds with optional filtering by active status, category, and country
// @Tags feeds
// @Produce json
// @Param page query int false "Page number" default(1) minimum(1)
// @Param per_page query int false "Items per page" default(50) minimum(1) maximum(100)
// @Param activo query string false "Active status filter"
// @Param categoria_id query string false "Category ID filter"
// @Param pais_id query string false "Country ID filter"
// @Success 200 {object} map[string]interface{} "feeds, total, page, per_page, total_pages"
// @Failure 500 {object} models.ErrorResponse
// @Router /feeds [get]
func GetFeeds(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
page, perPage = validatePageParams(page, perPage, 50, 100)
activo := c.Query("activo")
categoriaID := c.Query("categoria_id")
paisID := c.Query("pais_id")
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 50
}
offset := (page - 1) * perPage
// Cache key based on filters
filterStr := fmt.Sprintf("activo_%s_cat_%s_pais_%s", activo, categoriaID, paisID)
cacheKey := cache.FeedListKey(filterStr, page, perPage)
ctx := c.Request.Context()
// Try to get from cache
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
c.Data(http.StatusOK, "application/json", []byte(cached))
return
}
where := "1=1"
args := []interface{}{}
argNum := 1
@ -67,7 +74,7 @@ func GetFeeds(c *gin.Context) {
var total int
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM feeds WHERE %s", where)
err := db.GetPool().QueryRow(c.Request.Context(), countQuery, args...).Scan(&total)
err := db.GetPool().QueryRow(ctx, countQuery, args...).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count feeds", Message: err.Error()})
return
@ -75,7 +82,7 @@ func GetFeeds(c *gin.Context) {
sqlQuery := fmt.Sprintf(`
SELECT f.id, f.nombre, f.descripcion, f.url,
f.categoria_id, f.pais_id, f.idioma, f.activo, f.fallos, f.last_error,
f.categoria_id, f.pais_id, f.idioma, f.activo, f.fallos, f.last_error, f.last_fetched,
c.nombre AS categoria, p.nombre AS pais,
(SELECT COUNT(*) FROM noticias n WHERE n.fuente_nombre = f.nombre) as noticias_count
FROM feeds f
@ -88,7 +95,7 @@ func GetFeeds(c *gin.Context) {
args = append(args, perPage, offset)
rows, err := db.GetPool().Query(c.Request.Context(), sqlQuery, args...)
rows, err := db.GetPool().Query(ctx, sqlQuery, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to fetch feeds", Message: err.Error()})
return
@ -96,7 +103,7 @@ func GetFeeds(c *gin.Context) {
defer rows.Close()
type FeedWithStats struct {
FeedResponse
models.Feed
Categoria *string `json:"categoria"`
Pais *string `json:"pais"`
NoticiasCount int64 `json:"noticias_count"`
@ -107,7 +114,7 @@ func GetFeeds(c *gin.Context) {
var f FeedWithStats
err := rows.Scan(
&f.ID, &f.Nombre, &f.Descripcion, &f.URL,
&f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos, &f.LastError,
&f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos, &f.LastError, &f.LastFetched,
&f.Categoria, &f.Pais, &f.NoticiasCount,
)
if err != nil {
@ -118,13 +125,20 @@ func GetFeeds(c *gin.Context) {
totalPages := (total + perPage - 1) / perPage
c.JSON(http.StatusOK, gin.H{
response := gin.H{
"feeds": feeds,
"total": total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
})
}
// Cache the response
if data, err := json.Marshal(response); err == nil {
cache.Set(ctx, cacheKey, string(data), cache.TTLMedium)
}
c.JSON(http.StatusOK, response)
}
func GetFeedByID(c *gin.Context) {
@ -134,12 +148,12 @@ func GetFeedByID(c *gin.Context) {
return
}
var f FeedResponse
var f models.Feed
err = db.GetPool().QueryRow(c.Request.Context(), `
SELECT id, nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos
SELECT id, nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos, last_fetched
FROM feeds WHERE id = $1`, id).Scan(
&f.ID, &f.Nombre, &f.Descripcion, &f.URL,
&f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos,
&f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos, &f.LastFetched,
)
if err != nil {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
@ -178,6 +192,9 @@ func CreateFeed(c *gin.Context) {
return
}
// Invalidate feed list cache
cache.InvalidateFeedList(c.Request.Context())
c.JSON(http.StatusCreated, gin.H{"id": feedID, "message": "Feed created successfully"})
}
@ -228,6 +245,10 @@ func UpdateFeed(c *gin.Context) {
return
}
// Invalidate feed cache
cache.InvalidateFeed(c.Request.Context(), id)
cache.InvalidateFeedList(c.Request.Context())
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed updated successfully"})
}
@ -249,6 +270,10 @@ func DeleteFeed(c *gin.Context) {
return
}
// Invalidate feed cache
cache.InvalidateFeed(c.Request.Context(), id)
cache.InvalidateFeedList(c.Request.Context())
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed deleted successfully"})
}
@ -271,6 +296,10 @@ func ToggleFeedActive(c *gin.Context) {
return
}
// Invalidate feed cache
cache.InvalidateFeed(c.Request.Context(), id)
cache.InvalidateFeedList(c.Request.Context())
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed toggled successfully"})
}
@ -293,6 +322,10 @@ func ReactivateFeed(c *gin.Context) {
return
}
// Invalidate feed cache
cache.InvalidateFeed(c.Request.Context(), id)
cache.InvalidateFeedList(c.Request.Context())
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed reactivated successfully"})
}
@ -449,7 +482,12 @@ func ImportFeeds(c *gin.Context) {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to start transaction", Message: err.Error()})
return
}
defer tx.Rollback(context.Background())
committed := false
defer func() {
if !committed {
tx.Rollback(context.Background())
}
}()
field := func(record []string, idx int) string {
if idx < 0 || idx >= len(record) {
@ -579,7 +617,9 @@ func ImportFeeds(c *gin.Context) {
}
if err != nil {
tx.Exec(context.Background(), "ROLLBACK TO SAVEPOINT sp")
if _, rbErr := tx.Exec(context.Background(), "ROLLBACK TO SAVEPOINT sp"); rbErr != nil {
errors = append(errors, fmt.Sprintf("Rollback failed for %s: %v", url, rbErr))
}
failed++
errors = append(errors, fmt.Sprintf("Error upserting %s: %v", url, err))
continue
@ -598,6 +638,7 @@ func ImportFeeds(c *gin.Context) {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to commit transaction", Message: err.Error()})
return
}
committed = true
c.JSON(http.StatusOK, gin.H{
"imported": imported,

View file

@ -0,0 +1,35 @@
package handlers
import (
"testing"
)
func TestValidatePageParamsFeed(t *testing.T) {
tests := []struct {
name string
page int
perPage int
defaultPerPage int
maxPerPage int
expectedPage int
expectedPerPage int
}{
{"valid", 1, 50, 50, 100, 1, 50},
{"page zero", 0, 50, 50, 100, 1, 50},
{"per_page too large", 1, 200, 50, 100, 1, 100},
{"page negative", -5, 50, 50, 100, 1, 50},
{"per_page zero", 1, 0, 50, 100, 1, 50},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page, perPage := validatePageParams(tt.page, tt.perPage, tt.defaultPerPage, tt.maxPerPage)
if page != tt.expectedPage {
t.Errorf("page: expected %d, got %d", tt.expectedPage, page)
}
if perPage != tt.expectedPerPage {
t.Errorf("perPage: expected %d, got %d", tt.expectedPerPage, perPage)
}
})
}
}

View file

@ -2,6 +2,7 @@ package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
@ -9,40 +10,45 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/config"
"github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/models"
)
type NewsResponse struct {
ID string `json:"id"`
Titulo string `json:"titulo"`
Resumen string `json:"resumen"`
URL string `json:"url"`
Fecha *time.Time `json:"fecha"`
ImagenURL *string `json:"imagen_url"`
CategoriaID *int64 `json:"categoria_id"`
PaisID *int64 `json:"pais_id"`
FuenteNombre string `json:"fuente_nombre"`
TitleTranslated *string `json:"title_translated"`
SummaryTranslated *string `json:"summary_translated"`
LangTranslated *string `json:"lang_translated"`
Entities []Entity `json:"entities,omitempty"`
// getTargetLang returns the target language for translations, defaulting to config DefaultLang
func getTargetLang(c *gin.Context) string {
lang := c.Query("lang")
if lang == "" {
cfg := config.Load()
lang = cfg.DefaultLang
}
return lang
}
// GetNews returns paginated news with optional filters
// @Summary List news
// @Description Returns paginated news with optional filtering by query, category, country, and translation status
// @Tags news
// @Produce json
// @Param page query int false "Page number" default(1) minimum(1)
// @Param per_page query int false "Items per page" default(30) minimum(1) maximum(100)
// @Param q query string false "Search query"
// @Param category_id query string false "Category ID filter"
// @Param country_id query string false "Country ID filter"
// @Param lang query string false "Target translation language" default(es)
// @Param translated_only query string false "Only show translated news" default("false")
// @Success 200 {object} map[string]interface{} "news, total, page, per_page, total_pages"
// @Failure 500 {object} models.ErrorResponse
// @Router /news [get]
func GetNews(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "30"))
page, perPage = validatePageParams(page, perPage, 30, 100)
query := c.Query("q")
categoryID := c.Query("category_id")
countryID := c.Query("country_id")
translatedOnly := c.Query("translated_only") == "true"
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 30
}
targetLang := getTargetLang(c)
offset := (page - 1) * perPage
@ -53,7 +59,7 @@ func GetNews(c *gin.Context) {
if query != "" {
if translatedOnly {
// With translated_only, search the translated fields too so users can
// find translated news by the Spanish (translated) wording.
// find translated news by the target language wording.
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d OR t.titulo_trad ILIKE $%d OR t.resumen_trad ILIKE $%d)", argNum, argNum, argNum, argNum)
} else {
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d)", argNum, argNum)
@ -76,7 +82,8 @@ func GetNews(c *gin.Context) {
}
var total int
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM noticias n LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es' WHERE %s", where)
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM noticias n LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $%d WHERE %s", argNum, where)
args = append(args, targetLang)
err := db.GetPool().QueryRow(c.Request.Context(), countQuery, args...).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count news", Message: err.Error()})
@ -94,19 +101,18 @@ func GetNews(c *gin.Context) {
return
}
sqlQuery := fmt.Sprintf(`
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
n.categoria_id, n.pais_id, n.fuente_nombre,
sqlQuery := `
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.contenido, n.url, n.fecha, n.imagen_url,
n.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
t.titulo_trad,
t.resumen_trad,
t.lang_to as lang_trad
FROM noticias n
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es'
WHERE %s
ORDER BY n.fecha DESC LIMIT $%d OFFSET $%d
`, where, argNum, argNum+1)
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $` + strconv.Itoa(argNum) + `
WHERE ` + where + `
ORDER BY n.fecha DESC LIMIT $` + strconv.Itoa(argNum+1) + ` OFFSET $` + strconv.Itoa(argNum+2)
args = append(args, perPage, offset)
args = append(args, targetLang, perPage, offset)
rows, err := db.GetPool().Query(c.Request.Context(), sqlQuery, args...)
if err != nil {
@ -115,15 +121,16 @@ func GetNews(c *gin.Context) {
}
defer rows.Close()
var newsList []NewsResponse
var newsList []models.NewsWithTranslations
for rows.Next() {
var n NewsResponse
var n models.NewsWithTranslations
var imagenURL, fuenteNombre *string
var categoriaID, paisID *int32
var lang string
err := rows.Scan(
&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
&categoriaID, &paisID, &fuenteNombre,
&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &imagenURL,
&categoriaID, &paisID, &fuenteNombre, &lang,
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
)
if err != nil {
@ -137,12 +144,13 @@ func GetNews(c *gin.Context) {
}
if categoriaID != nil {
catID := int64(*categoriaID)
n.CategoriaID = &catID
n.CategoryID = &catID
}
if paisID != nil {
pID := int64(*paisID)
n.PaisID = &pID
n.CountryID = &pID
}
n.Lang = lang
newsList = append(newsList, n)
}
@ -161,18 +169,11 @@ func GetNews(c *gin.Context) {
func GetEntityNews(c *gin.Context) {
valor := c.Query("valor")
tipo := c.DefaultQuery("tipo", "persona")
pageStr := c.DefaultQuery("page", "1")
perPageStr := c.DefaultQuery("per_page", "20")
page, _ := strconv.Atoi(pageStr)
perPage, _ := strconv.Atoi(perPageStr)
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 50 {
perPage = 20
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
page, perPage = validatePageParams(page, perPage, 20, 50)
offset := (page - 1) * perPage
targetLang := getTargetLang(c)
daysStr := c.Query("days")
days := 0
@ -194,8 +195,8 @@ func GetEntityNews(c *gin.Context) {
useDayOffset = true
}
params := []interface{}{valor, tipo}
next := 3
params := []interface{}{valor, tipo, targetLang}
next := 4
timeCond := ""
switch {
case useDayOffset:
@ -214,7 +215,7 @@ func GetEntityNews(c *gin.Context) {
base := fmt.Sprintf(`
FROM tags_noticia tn
JOIN tags t ON tn.tag_id = t.id
JOIN traducciones tr ON tn.traduccion_id = tr.id
JOIN traducciones tr ON tn.traduccion_id = tr.id AND tr.lang_to = $3
JOIN noticias n ON tr.noticia_id = n.id
LEFT JOIN entity_aliases ea ON LOWER(ea.alias) = LOWER(t.valor) AND ea.tipo = t.tipo
WHERE LOWER(COALESCE(ea.canonical_name, t.valor)) = LOWER($1) AND t.tipo = $2%s`, timeCond)
@ -227,8 +228,8 @@ func GetEntityNews(c *gin.Context) {
}
query := fmt.Sprintf(`
SELECT DISTINCT n.id, n.titulo, COALESCE(n.resumen,''), n.url, n.fecha, n.imagen_url,
n.fuente_nombre, tr.titulo_trad, tr.resumen_trad
SELECT DISTINCT n.id, n.titulo, COALESCE(n.resumen,''), n.contenido, n.url, n.fecha, n.imagen_url,
n.fuente_nombre, n.lang, tr.titulo_trad, tr.resumen_trad
%s
ORDER BY n.fecha DESC
LIMIT $%d OFFSET $%d`, base, next, next+1)
@ -240,12 +241,12 @@ func GetEntityNews(c *gin.Context) {
}
defer rows.Close()
var news []NewsResponse
var news []models.NewsWithTranslations
for rows.Next() {
var n NewsResponse
var n models.NewsWithTranslations
var img *string
if err := rows.Scan(&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &img,
&n.FuenteNombre, &n.TitleTranslated, &n.SummaryTranslated); err != nil {
if err := rows.Scan(&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &img,
&n.FuenteNombre, &n.Lang, &n.TitleTranslated, &n.SummaryTranslated); err != nil {
continue
}
if img != nil {
@ -254,7 +255,7 @@ func GetEntityNews(c *gin.Context) {
news = append(news, n)
}
if news == nil {
news = []NewsResponse{}
news = []models.NewsWithTranslations{}
}
totalPages := (total + perPage - 1) / perPage
@ -268,27 +269,65 @@ func GetEntityNews(c *gin.Context) {
})
}
// GetNewsByID returns a single news item with entities
// @Summary Get news by ID
// @Description Returns a single news item with translations and associated entities
// @Tags news
// @Produce json
// @Param id path string true "News ID"
// @Param lang query string false "Target translation language" default(es)
// @Success 200 {object} models.NewsWithTranslations
// @Failure 404 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
// @Router /news/{id} [get]
func GetNewsByID(c *gin.Context) {
id := c.Param("id")
targetLang := getTargetLang(c)
// Combined query to fetch news with entities in one round trip
sqlQuery := `
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
n.categoria_id, n.pais_id, n.fuente_nombre,
SELECT
n.id, n.titulo, COALESCE(n.resumen, ''), n.contenido, n.url, n.fecha, n.imagen_url,
n.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
t.titulo_trad,
t.resumen_trad,
t.lang_to as lang_trad
t.lang_to as lang_trad,
json_agg(
json_build_object(
'valor', ent.valor,
'tipo', ent.tipo,
'count', ent.cnt,
'wiki_summary', ent.wiki_summary,
'wiki_url', ent.wiki_url,
'image_path', ent.image_path
)
) FILTER (WHERE ent.valor IS NOT NULL) as entities
FROM noticias n
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es'
WHERE n.id = $1`
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $1
LEFT JOIN (
SELECT tn.noticia_id, t.valor, t.tipo, 1 as cnt, t.wiki_summary, t.wiki_url, t.image_path
FROM tags_noticia tn
JOIN tags t ON tn.tag_id = t.id
JOIN traducciones tr ON tn.traduccion_id = tr.id
WHERE t.tipo IN ('persona', 'organizacion')
) ent ON ent.noticia_id = n.id
WHERE n.id = $2
GROUP BY n.id, n.titulo, n.resumen, n.contenido, n.url, n.fecha, n.imagen_url,
n.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
t.titulo_trad, t.resumen_trad, t.lang_to
`
var n NewsResponse
var n models.NewsWithTranslations
var imagenURL, fuenteNombre *string
var categoriaID, paisID *int32
var lang string
var entitiesJSON *string
err := db.GetPool().QueryRow(c.Request.Context(), sqlQuery, id).Scan(
&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
&categoriaID, &paisID, &fuenteNombre,
err := db.GetPool().QueryRow(c.Request.Context(), sqlQuery, targetLang, id).Scan(
&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &imagenURL,
&categoriaID, &paisID, &fuenteNombre, &lang,
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
&entitiesJSON,
)
if err != nil {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "News not found"})
@ -303,36 +342,23 @@ func GetNewsByID(c *gin.Context) {
}
if categoriaID != nil {
catID := int64(*categoriaID)
n.CategoriaID = &catID
n.CategoryID = &catID
}
if paisID != nil {
pID := int64(*paisID)
n.PaisID = &pID
n.CountryID = &pID
}
n.Lang = lang
// Fetch entities for this news
entitiesQuery := `
SELECT t.valor, t.tipo, 1 as cnt, t.wiki_summary, t.wiki_url, t.image_path
FROM tags_noticia tn
JOIN tags t ON tn.tag_id = t.id
JOIN traducciones tr ON tn.traduccion_id = tr.id
WHERE tr.noticia_id = $1 AND t.tipo IN ('persona', 'organizacion')
`
rows, err := db.GetPool().Query(c.Request.Context(), entitiesQuery, id)
var entities []Entity
if err == nil {
defer rows.Close()
for rows.Next() {
var e Entity
if err := rows.Scan(&e.Valor, &e.Tipo, &e.Count, &e.WikiSummary, &e.WikiURL, &e.ImagePath); err == nil {
entities = append(entities, e)
}
// Parse entities from JSON
if entitiesJSON != nil && *entitiesJSON != "null" {
var entities []models.Entity
if err := json.Unmarshal([]byte(*entitiesJSON), &entities); err == nil {
n.Entities = entities
}
} else {
n.Entities = []models.Entity{}
}
if entities == nil {
entities = []Entity{}
}
n.Entities = entities
c.JSON(http.StatusOK, n)
}
@ -354,23 +380,20 @@ func DeleteNews(c *gin.Context) {
c.JSON(http.StatusOK, models.SuccessResponse{Message: "News deleted successfully"})
}
type Entity struct {
Valor string `json:"valor"`
Tipo string `json:"tipo"`
Count int `json:"count"`
WikiSummary *string `json:"wiki_summary"`
WikiURL *string `json:"wiki_url"`
ImagePath *string `json:"image_path"`
}
type EntityListResponse struct {
Entities []Entity `json:"entities"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
}
// GetEntities returns paginated entities with optional filters
// @Summary List entities
// @Description Returns paginated entities (personas, organizations, locations) with optional filters
// @Tags entities
// @Produce json
// @Param tipo query string false "Entity type" default("persona") Enums(persona, organizacion, lugar, tema)
// @Param page query int false "Page number" default(1) minimum(1)
// @Param per_page query int false "Items per page" default(50) minimum(1) maximum(100)
// @Param country_id query string false "Country ID filter"
// @Param category_id query string false "Category ID filter"
// @Param q query string false "Search query"
// @Success 200 {object} models.EntityListResponse
// @Failure 500 {object} models.ErrorResponse
// @Router /entities [get]
func GetEntities(c *gin.Context) {
countryID := c.Query("country_id")
categoryID := c.Query("category_id")
@ -378,18 +401,9 @@ func GetEntities(c *gin.Context) {
q := c.Query("q")
pageStr := c.DefaultQuery("page", "1")
perPageStr := c.DefaultQuery("per_page", "50")
page, _ := strconv.Atoi(pageStr)
perPage, _ := strconv.Atoi(perPageStr)
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 50
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
page, perPage = validatePageParams(page, perPage, 50, 100)
offset := (page - 1) * perPage
@ -430,8 +444,8 @@ func GetEntities(c *gin.Context) {
}
if total == 0 {
c.JSON(http.StatusOK, EntityListResponse{
Entities: []Entity{},
c.JSON(http.StatusOK, models.EntityListResponse{
Entities: []models.Entity{},
Total: 0,
Page: page,
PerPage: perPage,
@ -463,9 +477,9 @@ func GetEntities(c *gin.Context) {
}
defer rows.Close()
var entities []Entity
var entities []models.Entity
for rows.Next() {
var e Entity
var e models.Entity
if err := rows.Scan(&e.Valor, &e.Tipo, &e.Count, &e.WikiSummary, &e.WikiURL, &e.ImagePath); err != nil {
continue
}
@ -473,12 +487,12 @@ func GetEntities(c *gin.Context) {
}
if entities == nil {
entities = []Entity{}
entities = []models.Entity{}
}
totalPages := (total + perPage - 1) / perPage
c.JSON(http.StatusOK, EntityListResponse{
c.JSON(http.StatusOK, models.EntityListResponse{
Entities: entities,
Total: total,
Page: page,
@ -487,23 +501,6 @@ func GetEntities(c *gin.Context) {
})
}
type MentionPoint struct {
Fecha string `json:"fecha"`
Count int `json:"count"`
}
type MentionSeries struct {
Valor string `json:"valor"`
Tipo string `json:"tipo"`
Count int `json:"count"`
Data []MentionPoint `json:"data"`
}
type MentionsResponse struct {
Days int `json:"days"`
Series []MentionSeries `json:"series"`
}
func GetEntityMentions(c *gin.Context) {
valuesRaw := c.DefaultQuery("values", "")
days := 30
@ -531,7 +528,7 @@ func GetEntityMentions(c *gin.Context) {
start := time.Now().AddDate(0, 0, -(days - 1)).Truncate(24 * time.Hour)
series := make([]MentionSeries, 0, len(values))
series := make([]models.MentionSeries, 0, len(values))
for _, valor := range values {
s, err := buildMentionSeries(c.Request.Context(), valor, days)
if err != nil {
@ -543,10 +540,10 @@ func GetEntityMentions(c *gin.Context) {
for _, p := range s.Data {
m[p.Fecha] = p.Count
}
data := make([]MentionPoint, 0, days)
data := make([]models.MentionPoint, 0, days)
for i := 0; i < days; i++ {
fecha := start.AddDate(0, 0, i).Format("2006-01-02")
data = append(data, MentionPoint{Fecha: fecha, Count: m[fecha]})
data = append(data, models.MentionPoint{Fecha: fecha, Count: m[fecha]})
}
s.Data = data
s.Count = 0
@ -559,11 +556,11 @@ func GetEntityMentions(c *gin.Context) {
series = append(series, *s)
}
c.JSON(http.StatusOK, MentionsResponse{Days: days, Series: series})
c.JSON(http.StatusOK, models.MentionsResponse{Days: days, Series: series})
}
func buildMentionSeries(ctx context.Context, valor string, days int) (*MentionSeries, error) {
s := &MentionSeries{Valor: valor}
func buildMentionSeries(ctx context.Context, valor string, days int) (*models.MentionSeries, error) {
s := &models.MentionSeries{Valor: valor}
var tagIDs []int64
rows, err := db.GetPool().Query(ctx, `
@ -590,7 +587,7 @@ func buildMentionSeries(ctx context.Context, valor string, days int) (*MentionSe
rows.Close()
if len(tagIDs) == 0 {
s.Data = []MentionPoint{}
s.Data = []models.MentionPoint{}
return s, nil
}
@ -615,10 +612,10 @@ func buildMentionSeries(ctx context.Context, valor string, days int) (*MentionSe
if err := drows.Scan(&fecha, &cnt); err != nil {
continue
}
s.Data = append(s.Data, MentionPoint{Fecha: fecha, Count: cnt})
s.Data = append(s.Data, models.MentionPoint{Fecha: fecha, Count: cnt})
}
if s.Data == nil {
s.Data = []MentionPoint{}
s.Data = []models.MentionPoint{}
}
return s, nil

View file

@ -0,0 +1,38 @@
package handlers
import (
"testing"
)
func TestValidatePageParams(t *testing.T) {
tests := []struct {
name string
page int
perPage int
defaultPerPage int
maxPerPage int
expectedPage int
expectedPerPage int
}{
{"normal", 1, 30, 30, 100, 1, 30},
{"page zero", 0, 30, 30, 100, 1, 30},
{"page negative", -5, 30, 30, 100, 1, 30},
{"per_page zero", 1, 0, 30, 100, 1, 30},
{"per_page negative", 1, -10, 30, 100, 1, 30},
{"per_page exceeds max", 1, 200, 30, 100, 1, 100},
{"page exceeds max", 999, 30, 30, 100, 999, 30},
{"custom max", 1, 200, 20, 50, 1, 50},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page, perPage := validatePageParams(tt.page, tt.perPage, tt.defaultPerPage, tt.maxPerPage)
if page != tt.expectedPage {
t.Errorf("page: expected %d, got %d", tt.expectedPage, page)
}
if perPage != tt.expectedPerPage {
t.Errorf("perPage: expected %d, got %d", tt.expectedPerPage, perPage)
}
})
}
}

View file

@ -1,12 +1,14 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/auth"
"github.com/rss2/backend/internal/cache"
"github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/models"
"github.com/rss2/backend/internal/services"
@ -79,10 +81,27 @@ func SearchSuggestions(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"terms": terms})
}
// SearchNews searches news with various filters
// @Summary Search news
// @Description Search news with text query, language, category, country, and semantic search options
// @Tags search
// @Produce json
// @Param q query string false "Search query"
// @Param page query int false "Page number" default(1) minimum(1)
// @Param per_page query int false "Items per page" default(30) minimum(1) maximum(100)
// @Param lang query string false "Language code" default("es")
// @Param categoria_id query string false "Category ID filter"
// @Param pais_id query string false "Country ID filter"
// @Param semantic query string false "Use semantic search" default("false")
// @Success 200 {object} map[string]interface{} "news, total, page, per_page, total_pages"
// @Failure 400 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
// @Router /search [get]
func SearchNews(c *gin.Context) {
query := c.Query("q")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "30"))
page, perPage = validatePageParams(page, perPage, 30, 100)
lang := c.DefaultQuery("lang", "")
categoriaID := c.Query("categoria_id")
paisID := c.Query("pais_id")
@ -93,13 +112,6 @@ func SearchNews(c *gin.Context) {
return
}
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 30
}
// Default to Spanish if no lang specified
if lang == "" {
lang = "es"
@ -117,6 +129,15 @@ func SearchNews(c *gin.Context) {
return
}
// Cache key for search results
cacheKey := cache.SearchKey(query, lang, page, perPage)
// Try to get from cache
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
c.Data(http.StatusOK, "application/json", []byte(cached))
return
}
offset := (page - 1) * perPage
// Build dynamic query
@ -147,8 +168,8 @@ func SearchNews(c *gin.Context) {
}
sqlQuery := `
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
n.categoria_id, n.pais_id, n.fuente_nombre,
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.contenido, n.url, n.fecha, n.imagen_url,
n.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
t.titulo_trad,
t.resumen_trad,
t.lang_to as lang_trad
@ -167,15 +188,16 @@ func SearchNews(c *gin.Context) {
}
defer rows.Close()
var newsList []NewsResponse
var newsList []models.NewsWithTranslations
for rows.Next() {
var n NewsResponse
var n models.NewsWithTranslations
var imagenURL, fuenteNombre *string
var categoriaIDp, paisIDp *int64
var lang string
err := rows.Scan(
&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
&categoriaIDp, &paisIDp, &fuenteNombre,
&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &imagenURL,
&categoriaIDp, &paisIDp, &fuenteNombre, &lang,
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
)
if err != nil {
@ -187,8 +209,9 @@ func SearchNews(c *gin.Context) {
if fuenteNombre != nil {
n.FuenteNombre = *fuenteNombre
}
n.CategoriaID = categoriaIDp
n.PaisID = paisIDp
n.CategoryID = categoriaIDp
n.CountryID = paisIDp
n.Lang = lang
newsList = append(newsList, n)
}
@ -206,19 +229,42 @@ func SearchNews(c *gin.Context) {
totalPages := (total + perPage - 1) / perPage
c.JSON(http.StatusOK, gin.H{
response := gin.H{
"news": newsList,
"total": total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
})
}
// Cache the response
if data, err := json.Marshal(response); err == nil {
cache.Set(ctx, cacheKey, string(data), cache.TTLShort)
}
c.JSON(http.StatusOK, response)
}
// GetStats returns global statistics
// @Summary Get statistics
// @Description Returns global statistics about news, feeds, users, and translations
// @Tags stats
// @Produce json
// @Success 200 {object} models.Stats
// @Failure 500 {object} models.ErrorResponse
// @Router /stats [get]
func GetStats(c *gin.Context) {
ctx := c.Request.Context()
cacheKey := cache.StatsKey()
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
c.Data(http.StatusOK, "application/json", []byte(cached))
return
}
var stats models.Stats
err := db.GetPool().QueryRow(c.Request.Context(), `
err := db.GetPool().QueryRow(ctx, `
SELECT
(SELECT COUNT(*) FROM noticias) as total_news,
(SELECT COUNT(*) FROM feeds WHERE activo = true) as total_feeds,
@ -237,7 +283,7 @@ func GetStats(c *gin.Context) {
return
}
rows, err := db.GetPool().Query(c.Request.Context(), `
rows, err := db.GetPool().Query(ctx, `
SELECT c.id, c.nombre, COUNT(n.id) as count
FROM categorias c
LEFT JOIN noticias n ON n.categoria_id = c.id
@ -249,12 +295,12 @@ func GetStats(c *gin.Context) {
defer rows.Close()
for rows.Next() {
var cs models.CategoryStat
rows.Scan(&cs.CategoryID, &cs.CategoryName, &cs.Count)
rows.Scan(&cs.CategoriaID, &cs.CategoriaName, &cs.Count)
stats.TopCategories = append(stats.TopCategories, cs)
}
}
rows, err = db.GetPool().Query(c.Request.Context(), `
rows, err = db.GetPool().Query(ctx, `
SELECT p.id, p.nombre, p.flag_emoji, COUNT(n.id) as count
FROM paises p
LEFT JOIN noticias n ON n.pais_id = p.id
@ -266,16 +312,28 @@ func GetStats(c *gin.Context) {
defer rows.Close()
for rows.Next() {
var cs models.CountryStat
rows.Scan(&cs.CountryID, &cs.CountryName, &cs.FlagEmoji, &cs.Count)
rows.Scan(&cs.PaisID, &cs.PaisName, &cs.FlagEmoji, &cs.Count)
stats.TopCountries = append(stats.TopCountries, cs)
}
}
if data, err := json.Marshal(stats); err == nil {
cache.Set(ctx, cacheKey, string(data), cache.TTLMedium)
}
c.JSON(http.StatusOK, stats)
}
func GetCategories(c *gin.Context) {
rows, err := db.GetPool().Query(c.Request.Context(), `
ctx := c.Request.Context()
cacheKey := cache.CategoriesKey()
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
c.Data(http.StatusOK, "application/json", []byte(cached))
return
}
rows, err := db.GetPool().Query(ctx, `
SELECT id, nombre FROM categorias ORDER BY nombre`)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get categories", Message: err.Error()})
@ -283,23 +341,30 @@ func GetCategories(c *gin.Context) {
}
defer rows.Close()
type Category struct {
ID int64 `json:"id"`
Nombre string `json:"nombre"`
}
var categories []Category
var categories []models.Category
for rows.Next() {
var cat Category
var cat models.Category
rows.Scan(&cat.ID, &cat.Nombre)
categories = append(categories, cat)
}
if data, err := json.Marshal(categories); err == nil {
cache.Set(ctx, cacheKey, string(data), cache.TTLLong)
}
c.JSON(http.StatusOK, categories)
}
func GetCountries(c *gin.Context) {
rows, err := db.GetPool().Query(c.Request.Context(), `
ctx := c.Request.Context()
cacheKey := cache.CountriesKey()
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
c.Data(http.StatusOK, "application/json", []byte(cached))
return
}
rows, err := db.GetPool().Query(ctx, `
SELECT p.id, p.nombre, c.nombre as continente
FROM paises p
LEFT JOIN continentes c ON c.id = p.continente_id
@ -310,18 +375,16 @@ func GetCountries(c *gin.Context) {
}
defer rows.Close()
type Country struct {
ID int64 `json:"id"`
Nombre string `json:"nombre"`
Continente string `json:"continente"`
}
var countries []Country
var countries []models.Country
for rows.Next() {
var country Country
var country models.Country
rows.Scan(&country.ID, &country.Nombre, &country.Continente)
countries = append(countries, country)
}
if data, err := json.Marshal(countries); err == nil {
cache.Set(ctx, cacheKey, string(data), cache.TTLLong)
}
c.JSON(http.StatusOK, countries)
}

View file

@ -0,0 +1,35 @@
package handlers
import (
"testing"
)
func TestValidatePageParamsSearch(t *testing.T) {
tests := []struct {
name string
page int
perPage int
defaultPerPage int
maxPerPage int
expectedPage int
expectedPerPage int
}{
{"normal", 1, 30, 30, 100, 1, 30},
{"page zero", 0, 30, 30, 100, 1, 30},
{"per_page too large", 1, 200, 30, 100, 1, 100},
{"page negative", -5, 30, 30, 100, 1, 30},
{"per_page zero", 1, 0, 30, 100, 1, 30},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page, perPage := validatePageParams(tt.page, tt.perPage, tt.defaultPerPage, tt.maxPerPage)
if page != tt.expectedPage {
t.Errorf("page: expected %d, got %d", tt.expectedPage, page)
}
if perPage != tt.expectedPerPage {
t.Errorf("perPage: expected %d, got %d", tt.expectedPerPage, perPage)
}
})
}
}

View file

@ -0,0 +1,14 @@
package handlers
func validatePageParams(page, perPage, defaultPerPage, maxPerPage int) (int, int) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = defaultPerPage
}
if perPage > maxPerPage {
perPage = maxPerPage
}
return page, perPage
}

View file

@ -0,0 +1,60 @@
package logger
import (
"os"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
var Logger zerolog.Logger
func Init(serviceName, level string) {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs
zerolog.CallerMarshalFunc = func(pc uintptr, file string, line int) string {
short := file
for i := len(file) - 1; i > 0; i-- {
if file[i] == '/' {
short = file[i+1:]
break
}
}
return short + ":" + string(rune(line))
}
output := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339, NoColor: false}
logger := zerolog.New(output).With().Timestamp().Caller().Str("service", serviceName).Logger()
switch level {
case "debug":
logger = logger.Level(zerolog.DebugLevel)
case "info":
logger = logger.Level(zerolog.InfoLevel)
case "warn":
logger = logger.Level(zerolog.WarnLevel)
case "error":
logger = logger.Level(zerolog.ErrorLevel)
default:
logger = logger.Level(zerolog.InfoLevel)
}
Logger = logger
log.Logger = logger
}
func GetLogger() zerolog.Logger {
return Logger
}
func WithRequestID(requestID string) zerolog.Logger {
return Logger.With().Str("request_id", requestID).Logger()
}
func WithFields(fields map[string]interface{}) zerolog.Logger {
ctx := Logger.With()
for k, v := range fields {
ctx = ctx.Interface(k, v)
}
return ctx.Logger()
}

View file

@ -1,12 +1,17 @@
package middleware
import (
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/auth"
"github.com/rss2/backend/internal/config"
"github.com/rss2/backend/internal/logger"
"golang.org/x/time/rate"
)
func AuthRequired() gin.HandlerFunc {
@ -63,23 +68,19 @@ func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
allowed := false
for _, o := range allowedOrigins {
o = strings.TrimSpace(o)
if o == "*" || o == origin {
if o == origin {
allowed = true
break
}
}
if allowed {
if cfg.AllowedOrigins == "*" {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
} else {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
}
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
}
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
@ -96,17 +97,101 @@ func CORSMiddleware() gin.HandlerFunc {
func LoggerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = c.GetString("request_id")
}
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
if status >= 400 {
// Log error responses
clientIP := c.ClientIP()
method := c.Request.Method
if raw != "" {
path = path + "?" + raw
}
log := logger.GetLogger().With().
Str("method", method).
Str("path", path).
Int("status", status).
Str("client_ip", clientIP).
Dur("latency", latency).
Logger()
if requestID != "" {
log = log.With().Str("request_id", requestID).Logger()
}
if status >= 500 {
log.Error().Msg("Server error")
} else if status >= 400 {
log.Warn().Msg("Client error")
} else {
log.Info().Msg("Request completed")
}
}
}
type clientLimiter struct {
limiter *rate.Limiter
lastSeen time.Time
}
var (
clientLimiters = make(map[string]*clientLimiter)
limitersMu sync.Mutex
)
func RateLimitMiddleware(requestsPerMinute int) gin.HandlerFunc {
limit := rate.Limit(requestsPerMinute) / 60
burst := requestsPerMinute
return func(c *gin.Context) {
ip := c.ClientIP()
limitersMu.Lock()
cl, exists := clientLimiters[ip]
if !exists {
cl = &clientLimiter{limiter: rate.NewLimiter(limit, burst)}
clientLimiters[ip] = cl
}
cl.lastSeen = time.Now()
limiter := cl.limiter
limitersMu.Unlock()
if !limiter.Allow() {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Rate limit exceeded"})
c.Abort()
return
}
go func() {
time.Sleep(10 * time.Minute)
limitersMu.Lock()
if cl, ok := clientLimiters[ip]; ok && time.Since(cl.lastSeen) > 10*time.Minute {
delete(clientLimiters, ip)
}
limitersMu.Unlock()
}()
c.Next()
}
}
func RequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = fmt.Sprintf("%d", time.Now().UnixNano())
}
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
c.Next()
}
}

View file

@ -6,12 +6,12 @@ import (
type News struct {
ID int64 `json:"id"`
Title string `json:"title"`
Summary string `json:"summary"`
Content string `json:"content"`
Titulo string `json:"titulo"`
Resumen string `json:"resumen"`
Contenido string `json:"contenido"`
URL string `json:"url"`
ImageURL *string `json:"image_url"`
PublishedAt *time.Time `json:"published_at"`
ImagenURL *string `json:"imagen_url"`
Fecha *time.Time `json:"fecha"`
Lang string `json:"lang"`
FeedID int64 `json:"feed_id"`
CreatedAt time.Time `json:"created_at"`
@ -20,35 +20,73 @@ type News struct {
type NewsWithTranslations struct {
ID int64 `json:"id"`
Title string `json:"title"`
Summary string `json:"summary"`
Content string `json:"content"`
Titulo string `json:"titulo"`
Resumen string `json:"resumen"`
Contenido string `json:"contenido"`
URL string `json:"url"`
ImageURL *string `json:"image_url"`
PublishedAt *string `json:"published_at"`
ImagenURL *string `json:"imagen_url"`
Fecha *string `json:"fecha"`
Lang string `json:"lang"`
FeedID int64 `json:"feed_id"`
CategoryID *int64 `json:"category_id"`
CountryID *int64 `json:"country_id"`
CategoryID *int64 `json:"categoria_id"`
CountryID *int64 `json:"pais_id"`
FuenteNombre string `json:"fuente_nombre"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
TitleTranslated *string `json:"title_translated"`
SummaryTranslated *string `json:"summary_translated"`
ContentTranslated *string `json:"content_translated"`
LangTranslated *string `json:"lang_translated"`
Entities []Entity `json:"entities,omitempty"`
}
type Entity struct {
Valor string `json:"valor"`
Tipo string `json:"tipo"`
Count int `json:"count"`
WikiSummary *string `json:"wiki_summary"`
WikiURL *string `json:"wiki_url"`
ImagePath *string `json:"image_path"`
}
type EntityListResponse struct {
Entities []Entity `json:"entities"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
}
type MentionPoint struct {
Fecha string `json:"fecha"`
Count int `json:"count"`
}
type MentionSeries struct {
Valor string `json:"valor"`
Tipo string `json:"tipo"`
Count int `json:"count"`
Data []MentionPoint `json:"data"`
}
type MentionsResponse struct {
Days int `json:"days"`
Series []MentionSeries `json:"series"`
}
type Feed struct {
ID int64 `json:"id"`
Title string `json:"title"`
Nombre string `json:"nombre"`
URL string `json:"url"`
SiteURL *string `json:"site_url"`
Description *string `json:"description"`
ImageURL *string `json:"image_url"`
Language *string `json:"language"`
CategoryID *int64 `json:"category_id"`
CountryID *int64 `json:"country_id"`
Active bool `json:"active"`
Descripcion *string `json:"descripcion"`
ImagenURL *string `json:"imagen_url"`
Idioma *string `json:"idioma"`
CategoriaID *int64 `json:"categoria_id"`
PaisID *int64 `json:"pais_id"`
Activo bool `json:"activo"`
Fallos *int64 `json:"fallos"`
LastError *string `json:"last_error"`
LastFetched *time.Time `json:"last_fetched"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@ -56,27 +94,27 @@ type Feed struct {
type Category struct {
ID int64 `json:"id"`
Name string `json:"name"`
Nombre string `json:"nombre"`
Color string `json:"color"`
Icon string `json:"icon"`
ParentID *int64 `json:"parent_id"`
}
type Country struct {
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
Continent string `json:"continent"`
FlagEmoji string `json:"flag_emoji"`
ID int64 `json:"id"`
Nombre string `json:"nombre"`
Codigo string `json:"codigo"`
Continente string `json:"continente"`
FlagEmoji string `json:"flag_emoji"`
}
type Translation struct {
ID int64 `json:"id"`
NewsID int64 `json:"news_id"`
NoticiaID int64 `json:"noticia_id"`
LangFrom string `json:"lang_from"`
LangTo string `json:"lang_to"`
Title string `json:"title"`
Summary string `json:"summary"`
Titulo string `json:"titulo"`
Resumen string `json:"resumen"`
Status string `json:"status"`
Error *string `json:"error"`
CreatedAt time.Time `json:"created_at"`
@ -89,6 +127,7 @@ type User struct {
Username string `json:"username"`
PasswordHash string `json:"-"`
IsAdmin bool `json:"is_admin"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@ -97,8 +136,8 @@ type SearchHistory struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Query string `json:"query"`
CategoryID *int64 `json:"category_id"`
CountryID *int64 `json:"country_id"`
CategoriaID *int64 `json:"categoria_id"`
PaisID *int64 `json:"pais_id"`
ResultsCount int `json:"results_count"`
SearchedAt time.Time `json:"searched_at"`
}
@ -132,14 +171,14 @@ type Stats struct {
}
type CategoryStat struct {
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"`
Count int64 `json:"count"`
CategoriaID int64 `json:"categoria_id"`
CategoriaName string `json:"categoria_nombre"`
Count int64 `json:"count"`
}
type CountryStat struct {
CountryID int64 `json:"country_id"`
CountryName string `json:"country_name"`
PaisID int64 `json:"pais_id"`
PaisName string `json:"pais_nombre"`
FlagEmoji string `json:"flag_emoji"`
Count int64 `json:"count"`
}

View file

@ -8,10 +8,9 @@ import (
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rss2/backend/internal/db"
)
var pool *pgxpool.Pool
type Config struct {
Host string
Port int
@ -31,39 +30,23 @@ func LoadDBConfig() *Config {
}
func Connect(cfg *Config) error {
dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
poolConfig, err := pgxpool.ParseConfig(dsn)
if err != nil {
return fmt.Errorf("failed to parse config: %w", err)
// Just verify the main pool is accessible
if db.GetPool() == nil {
return fmt.Errorf("database pool not initialized")
}
poolConfig.MaxConns = 25
poolConfig.MinConns = 5
poolConfig.MaxConnLifetime = time.Hour
poolConfig.MaxConnIdleTime = 30 * time.Minute
pool, err = pgxpool.NewWithConfig(context.Background(), poolConfig)
if err != nil {
return fmt.Errorf("failed to create pool: %w", err)
}
if err = pool.Ping(context.Background()); err != nil {
return fmt.Errorf("failed to ping database: %w", err)
}
return nil
return db.HealthCheck(context.Background())
}
func GetPool() *pgxpool.Pool {
return pool
return db.GetPool()
}
func Close() {
if pool != nil {
pool.Close()
}
// No-op: main db package manages the pool
}
func HealthCheck(ctx context.Context) error {
return db.HealthCheck(ctx)
}
func getEnv(key, defaultValue string) string {

View file

@ -340,12 +340,12 @@ services:
condition: service_healthy
restart: unless-stopped
# Prioridad BAJA: el traductor no debe acaparar toda la CPU.
# cpu_shares menor que el resto (1024) + tope de 2 núcleos dejan CPU para NER, embeddings, etc.
# cpu_shares menor que el resto (1024) + tope que deje CPU para NER, embeddings, etc.
cpu_shares: 512
deploy:
resources:
limits:
cpus: '2'
cpus: '4'
memory: 6G
# ==================================================================================
@ -390,7 +390,7 @@ services:
deploy:
resources:
limits:
cpus: '2'
cpus: '4'
memory: 6G
# ==================================================================================

View file

@ -99,8 +99,16 @@
"Pakistán",
"Tolo News",
"Gladbach",
"Alger"
"Alger",
"Comisión",
"Dirección",
"Más",
"Regístrese",
"Consejo",
"Fondo",
"PVT LTD",
"Beneficios",
"Ingrese"
],
"synonyms": {
"Alassane Ouattara": [

View file

@ -1,6 +1,6 @@
import { Outlet, Link, useNavigate } from 'react-router-dom'
import { Search, Rss, BarChart3, Home as HomeIcon, Heart, User, Flame, Settings, Users, Tags, Database, Server, TrendingUp, Bell } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Search, Rss, BarChart3, Home as HomeIcon, Heart, User, Flame, Settings, Users, Tags, Database, Server, TrendingUp, Bell, X, Menu } from 'lucide-react'
import { useEffect, useState, useRef } from 'react'
import { apiService } from '../../services/api'
export function Layout() {

View file

@ -0,0 +1,166 @@
import { Search, Rss, Globe, AlertTriangle, Inbox } from 'lucide-react'
interface EmptyStateProps {
icon?: 'search' | 'rss' | 'globe' | 'alert' | 'inbox'
title: string
description: string
action?: {
label: string
onClick: () => void
}
className?: string
}
export function EmptyState({
icon = 'search',
title,
description,
action,
className = ''
}: EmptyStateProps) {
const icons = {
search: Search,
rss: Rss,
globe: Globe,
alert: AlertTriangle,
inbox: Inbox,
}
const Icon = icons[icon]
return (
<div className={`flex flex-col items-center justify-center py-16 px-4 text-center ${className}`}>
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<Icon className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">{title}</h3>
<p className="text-gray-500 max-w-md mb-6">{description}</p>
{action && (
<button
onClick={action.onClick}
className="btn-primary"
>
{action.label}
</button>
)}
</div>
)
}
export function NoResultsFound({
query,
onClearFilters
}: {
query?: string
onClearFilters?: () => void
}) {
return (
<EmptyState
icon="search"
title="No se encontraron resultados"
description={query
? `No hay noticias que coincidan con "${query}". Intenta con otros términos de búsqueda.`
: 'No hay noticias que coincidan con los filtros seleccionados.'}
action={onClearFilters ? {
label: 'Limpiar filtros',
onClick: onClearFilters
} : undefined}
/>
)
}
export function NoNewsYet({
onAddFeed
}: {
onAddFeed?: () => void
}) {
return (
<EmptyState
icon="rss"
title="No hay noticias todavía"
description="Aún no se han agregado fuentes RSS. Agrega tu primera fuente para empezar a ver noticias."
action={onAddFeed ? {
label: 'Agregar fuente RSS',
onClick: onAddFeed
} : undefined}
/>
)
}
export function NoFeedsConfigured({
onAddFeed
}: {
onAddFeed?: () => void
}) {
return (
<EmptyState
icon="rss"
title="No hay fuentes configuradas"
description="No se han agregado fuentes RSS. Agrega tu primera fuente para empezar a recibir noticias."
action={onAddFeed ? {
label: 'Agregar fuente RSS',
onClick: onAddFeed
} : undefined}
/>
)
}
export function NoSearchResults({
query,
onSearch
}: {
query?: string
onSearch?: () => void
}) {
return (
<EmptyState
icon="search"
title="Sin resultados"
description={query
? `No se encontraron resultados para "${query}". Prueba con términos más generales.`
: 'Ingresa un término de búsqueda para encontrar noticias.'}
action={onSearch ? {
label: 'Nueva búsqueda',
onClick: onSearch
} : undefined}
/>
)
}
export function OfflineState({
onRetry
}: {
onRetry?: () => void
}) {
return (
<EmptyState
icon="alert"
title="Sin conexión"
description="No se puede conectar al servidor. Verifica tu conexión a internet e intenta de nuevo."
action={onRetry ? {
label: 'Reintentar',
onClick: onRetry
} : undefined}
/>
)
}
export function ErrorState({
message = 'Ha ocurrido un error inesperado',
onRetry
}: {
message?: string
onRetry?: () => void
}) {
return (
<EmptyState
icon="alert"
title="Error"
description={message}
action={onRetry ? {
label: 'Reintentar',
onClick: onRetry
} : undefined}
/>
)
}

View file

@ -0,0 +1,110 @@
export function SkeletonNewsCard() {
return (
<div className="card animate-pulse">
<div className="w-full h-48 bg-gray-200 rounded-t-xl"></div>
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<div className="h-4 w-24 bg-gray-200 rounded"></div>
<div className="h-4 w-16 bg-gray-200 rounded"></div>
</div>
<div className="h-5 w-3/4 bg-gray-200 rounded"></div>
<div className="h-4 w-full bg-gray-200 rounded"></div>
<div className="h-4 w-5/6 bg-gray-200 rounded"></div>
<div className="h-4 w-1/2 bg-gray-200 rounded"></div>
</div>
</div>
)
}
interface SkeletonNewsListProps {
count?: number
}
export function SkeletonNewsList({ count = 6 }: SkeletonNewsListProps) {
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: count }).map((_, i) => (
<SkeletonNewsCard key={i} />
))}
</div>
)
}
export function SkeletonFeedCard() {
return (
<div className="card p-4 animate-pulse">
<div className="flex items-center gap-4">
<div className="h-10 w-10 bg-gray-200 rounded-lg"></div>
<div className="flex-1 space-y-2">
<div className="h-5 w-1/3 bg-gray-200 rounded"></div>
<div className="h-4 w-1/2 bg-gray-200 rounded"></div>
</div>
<div className="h-6 w-20 bg-gray-200 rounded"></div>
</div>
</div>
)
}
interface SkeletonFeedListProps {
count?: number
}
export function SkeletonFeedList({ count = 5 }: SkeletonFeedListProps) {
return (
<div className="space-y-3">
{Array.from({ length: count }).map((_, i) => (
<SkeletonFeedCard key={i} />
))}
</div>
)
}
export function SkeletonEntityCard() {
return (
<div className="card p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="h-12 w-12 bg-gray-200 rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-5 w-1/3 bg-gray-200 rounded"></div>
<div className="h-3 w-1/4 bg-gray-200 rounded"></div>
</div>
</div>
</div>
)
}
interface SkeletonEntityListProps {
count?: number
}
export function SkeletonEntityList({ count = 4 }: SkeletonEntityListProps) {
return (
<div className="space-y-3">
{Array.from({ length: count }).map((_, i) => (
<SkeletonEntityCard key={i} />
))}
</div>
)
}
export function SkeletonStats() {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="card p-6 animate-pulse">
<div className="h-4 w-1/3 bg-gray-200 rounded mb-2"></div>
<div className="h-8 w-1/2 bg-gray-200 rounded"></div>
</div>
))}
</div>
)
}
export function SkeletonChart() {
return (
<div className="card p-6 animate-pulse">
<div className="h-4 w-1/4 bg-gray-200 rounded mb-4"></div>
<div className="h-64 bg-gray-100 rounded"></div>
</div>
)
}

View file

@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useRef, useEffect } from 'react';
import { ExternalLink } from 'lucide-react';
interface WikiTooltipProps {
@ -14,19 +14,43 @@ export function WikiTooltip({ children, summary, imagePath, wikiUrl, name }: Wik
return <span className="text-gray-900">{children}</span>;
}
const tooltipRef = useRef<HTMLDivElement>(null)
// Close tooltip on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
// Force blur to close tooltip
const activeElement = document.activeElement as HTMLElement
activeElement?.blur()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
return (
<div className="relative group inline-block">
<span className="cursor-help underline decoration-dotted decoration-gray-400 underline-offset-4 text-primary-700 font-medium">
<div className="relative group inline-block" role="tooltip">
<span
className="cursor-help underline decoration-dotted decoration-gray-400 underline-offset-4 text-primary-700 font-medium focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded"
tabIndex={0}
aria-describedby={`wiki-tooltip-${name.replace(/\s+/g, '-')}`}
>
{children}
</span>
<div className="absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-72 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-300 ease-out pointer-events-none group-hover:pointer-events-auto origin-bottom transform scale-95 group-hover:scale-100">
<div
id={`wiki-tooltip-${name.replace(/\s+/g, '-')}`}
ref={tooltipRef}
className="absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-72 opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-300 ease-out pointer-events-none group-hover:pointer-events-auto group-focus-within:pointer-events-auto origin-bottom transform scale-95 group-hover:scale-100 group-focus-within:scale-100"
role="tooltip"
>
<div className="bg-white rounded-xl shadow-[0_10px_40px_-10px_rgba(0,0,0,0.15)] border border-gray-100 overflow-hidden text-left flex flex-col">
{imagePath && (
<div className="w-full h-44 bg-gray-100 overflow-hidden relative border-b border-gray-100">
<img
src={imagePath}
alt={name}
alt={`${name} - Wikipedia image`}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
@ -45,18 +69,18 @@ export function WikiTooltip({ children, summary, imagePath, wikiUrl, name }: Wik
<a
href={wikiUrl}
target="_blank"
rel="noreferrer"
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-primary-600 hover:text-primary-800 transition-colors"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-primary-600 hover:text-primary-800 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded"
onClick={(e) => e.stopPropagation()}
>
Ver en Wikipedia <ExternalLink className="h-3 w-3" />
Ver en Wikipedia <ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)}
</div>
</div>
{/* Triangle arrow */}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-[1px] border-8 border-transparent border-t-white z-10"></div>
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-[0px] border-8 border-transparent border-t-gray-100 -z-10 blur-[1px]"></div>
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-[1px] border-8 border-transparent border-t-white z-10" aria-hidden="true"></div>
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-[0px] border-8 border-transparent border-t-gray-100 -z-10 blur-[1px]" aria-hidden="true"></div>
</div>
</div>
);

View file

@ -0,0 +1,3 @@
export { SkeletonNewsCard, SkeletonNewsList, SkeletonFeedCard, SkeletonFeedList, SkeletonEntityCard, SkeletonEntityList, SkeletonStats, SkeletonChart } from './Skeleton'
export { EmptyState, NoResultsFound, NoNewsYet, NoFeedsConfigured, NoSearchResults, OfflineState, ErrorState } from './EmptyState'
export { WikiTooltip } from './WikiTooltip'

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { apiService, Alerta } from '../services/api'
import { Bell, CheckCheck, ChevronDown, ChevronUp } from 'lucide-react'
import { Link } from 'react-router-dom'
import { apiService, api, Alerta, News } from '../services/api'
import { Bell, CheckCheck, ChevronDown, ChevronUp, FileText } from 'lucide-react'
const TIPO_LABEL: Record<string, string> = {
persona: 'Persona',
@ -15,6 +16,11 @@ export function Alertas() {
const [nuevas, setNuevas] = useState(0)
const [limit, setLimit] = useState(50)
const [newsModal, setNewsModal] = useState<Alerta | null>(null)
const [entityNews, setEntityNews] = useState<News[]>([])
const [newsLoading, setNewsLoading] = useState(false)
const [newsTotal, setNewsTotal] = useState(0)
const load = async () => {
setLoading(true)
try {
@ -42,6 +48,26 @@ export function Alertas() {
load()
}
const openNews = async (alerta: Alerta) => {
setNewsModal(alerta)
setNewsLoading(true)
setEntityNews([])
setNewsTotal(0)
try {
const params = new URLSearchParams()
params.append('valor', alerta.valor)
params.append('tipo', alerta.tipo)
params.append('per_page', '50')
const res = await api.get(`/entities/news?${params}`)
setEntityNews(res.data.news || [])
setNewsTotal(res.data.total || 0)
} catch (err) {
console.error(err)
} finally {
setNewsLoading(false)
}
}
return (
<div>
<div className="flex justify-between items-center mb-8">
@ -108,13 +134,17 @@ export function Alertas() {
</span>
</td>
<td className="px-4 py-3">
{a.status === 'nueva' ? (
<button onClick={() => markRead(a.id)} className="btn-secondary text-xs py-1 px-3">
Marcar leída
<div className="flex items-center gap-2">
<button onClick={() => openNews(a)} className="btn-secondary text-xs py-1 px-3 flex items-center gap-1">
<FileText className="h-3.5 w-3.5" />
Noticias
</button>
) : (
<span className="text-sm text-gray-300 dark:text-gray-600"></span>
)}
{a.status === 'nueva' && (
<button onClick={() => markRead(a.id)} className="btn-secondary text-xs py-1 px-3">
Marcar leída
</button>
)}
</div>
</td>
</tr>
))}
@ -132,6 +162,75 @@ export function Alertas() {
Mostrar {limit > 50 ? 'menos' : 'más'} ({limit})
</button>
</div>
{/* Noticias relacionadas del concepto */}
{newsModal && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={() => setNewsModal(null)}>
<div
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
<div>
<h2 className="text-lg font-bold">Noticias sobre &quot;{newsModal.valor}&quot;</h2>
<p className="text-sm text-gray-500 mt-0.5">
{newsTotal} noticias encontradas · {TIPO_LABEL[newsModal.tipo] || newsModal.tipo} · {newsModal.periodo}
</p>
</div>
<button
onClick={() => setNewsModal(null)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
>
</button>
</div>
<div className="p-4 overflow-y-auto">
{newsLoading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div>
) : entityNews.length === 0 ? (
<div className="text-center py-10 text-gray-400">No hay noticias que mencionen a este concepto.</div>
) : (
<ul className="space-y-2">
{entityNews.map((n) => (
<li key={n.id}>
<Link
to={`/news/${n.id}`}
onClick={() => setNewsModal(null)}
className="block p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border border-gray-100 dark:border-gray-700"
>
<div className="flex items-start gap-3">
{n.imagen_url && (
<img
src={n.imagen_url}
alt=""
className="w-20 h-14 object-cover rounded-md shrink-0"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
)}
<div className="min-w-0">
<div className="font-medium text-sm line-clamp-2">
{n.title_translated || n.titulo}
</div>
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{n.summary_translated || n.resumen}
</p>
{n.fecha && (
<span className="text-[11px] text-gray-400 mt-1 block">
{new Date(n.fecha).toLocaleDateString('es-ES')}
</span>
)}
</div>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)}
</div>
)
}

View file

@ -5,6 +5,7 @@ import { formatDistanceToNow } from 'date-fns'
import { es } from 'date-fns/locale'
import { apiService, api } from '../services/api'
import { Search, Globe, Newspaper, Filter } from 'lucide-react'
import { SkeletonNewsList, NoResultsFound } from '../components/ui'
export function Home() {
const [searchParams, setSearchParams] = useSearchParams()
@ -156,13 +157,22 @@ export function Home() {
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
<SkeletonNewsList count={6} />
) : error ? (
<div className="text-center py-12 text-red-600">
Error al cargar noticias
<button
onClick={() => window.location.reload()}
className="btn-primary mt-4"
>
Reintentar
</button>
</div>
) : data?.news?.length === 0 ? (
<NoResultsFound
query={q}
onClearFilters={() => setSearchParams({ page: '1', q })}
/>
) : (
<>
<div className="mb-4 text-sm text-gray-600">

View file

@ -33,13 +33,13 @@ const TIPOS = [
{ value: 'tema', label: '📰 Tema', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
]
const TIMELINE_WINDOWS: { key: string; label: string; title: string; offset: number }[] = [
{ key: '0', label: 'Hoy', title: 'hoy', offset: 0 },
{ key: '1', label: '1d', title: 'hace 1 día', offset: 1 },
{ key: '2', label: '2d', title: 'hace 2 días', offset: 2 },
{ key: '3', label: '3d', title: 'hace 3 días', offset: 3 },
{ key: '4', label: '4d', title: 'hace 4 días', offset: 4 },
{ key: '5', label: '5d', title: 'hace 5 días', offset: 5 },
const TIMELINE_WINDOWS: { key: string; label: string; title: string; days: number }[] = [
{ key: '1', label: '24h', title: 'últimas 24 horas', days: 1 },
{ key: '2', label: '2 días', title: 'últimos 2 días', days: 2 },
{ key: '3', label: '3 días', title: 'últimos 3 días', days: 3 },
{ key: '4', label: '4 días', title: 'últimos 4 días', days: 4 },
{ key: '5', label: '5 días', title: 'últimos 5 días', days: 5 },
{ key: '7', label: '7 días', title: 'últimos 7 días', days: 7 },
]
function getTipoInfo(tipo: string) {
@ -61,6 +61,8 @@ export function Populares() {
const [configModal, setConfigModal] = useState<ConfigModal | null>(null)
const [saving, setSaving] = useState(false)
const [successMsg, setSuccessMsg] = useState('')
const [expandedEntities, setExpandedEntities] = useState<Set<string>>(new Set())
const [allExpanded, setAllExpanded] = useState(false)
// News-by-entity modal state
const [newsModal, setNewsModal] = useState<Entity | null>(null)
@ -158,7 +160,7 @@ export function Populares() {
const params = new URLSearchParams()
params.append('valor', entity.valor)
params.append('tipo', entity.tipo)
params.append('day_offset', String(win.offset))
params.append('days', String(win.days))
params.append('per_page', '50')
const res = await api.get(`/entities/news?${params}`)
setEntityNews(res.data.news || [])
@ -270,6 +272,29 @@ export function Populares() {
<h1 className="text-2xl font-bold">
🔥 Populares {selectedCountry ? `en ${selectedCountry.nombre}` : 'Global'}
</h1>
<div className="flex items-center gap-4">
<button
onClick={() => setAllExpanded(p => !p)}
className="px-3 py-1.5 text-sm border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 transition-colors"
title="Expandir/collapse todo"
>
{allExpanded ? '← Contraer todas' : '→ Expandir todas'}
</button>
<button
onClick={() => setExpandedEntities(p => p.clear())}
className="px-3 py-1.5 text-sm border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 transition-colors"
title="Contraer todas"
>
Contraer
</button>
<button
onClick={() => setExpandedEntities(p => p.add('all'))}
className="px-3 py-1.5 text-sm border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 transition-colors"
title="Expandir todas"
>
Expandir
</button>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={handleExportAliases}
@ -359,96 +384,102 @@ export function Populares() {
</div>
</div>
{/* Table */}
{/* Listado de entidades */}
{loading ? (
<div className="text-center py-12 text-gray-400">Cargando...</div>
) : entities.length === 0 ? (
<div className="text-center py-12 text-gray-400">No se encontraron entidades</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-10">#</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Entidad</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider hidden md:table-cell">Tipo</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Menciones</th>
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider w-24">Config</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{entities.map((entity, idx) => {
const tipoInfo = getTipoInfo(entity.tipo)
return (
<tr key={entity.valor + idx} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td className="px-4 py-3 text-gray-400 text-sm">{(page - 1) * 50 + idx + 1}</td>
<td className="px-4 py-3 font-medium">
<div className="p-4 bg-gray-50 dark:bg-gray-700 border-b dark:border-gray-700">
<div className="flex flex-col sm:flex-row items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">📊 {entities.length} entidades populares</span>
<div className="flex gap-1">
<button
onClick={() => setExpandedEntities(p => p.clear())}
className="px-2 py-1 text-[10px] text-gray-500 hover:text-blue-600 dark:hover:text-blue-300 rounded"
title="Contraer todas"
>
</button>
<button
onClick={() => setExpandedEntities(p => p.add('all'))}
className="px-2 py-1 text-[10px] text-gray-500 hover:text-blue-600 dark:hover:text-blue-300 rounded"
title="Expandir todas"
>
Expandir
</button>
</div>
</div>
</div>
<div className="p-4 space-y-2">
{entities.map((entity, idx) => {
const isExpanded = allExpanded || expandedEntities.has(entity.valor)
const tipoInfo = getTipoInfo(entity.tipo)
return (
<div
key={entity.valor}
className={`collapse ${isExpanded ? 'in' : ''} transition-colors duration-300 ease-in-out`}
>
<div className="flex items-center justify-between px-2 py-2 border-b dark:border-gray-700 last:border-0">
<div className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => setExpandedEntities(p => p.toggle(entity.valor))}>
<WikiTooltip
name={entity.valor}
summary={entity.wiki_summary}
imagePath={entity.image_path}
wikiUrl={entity.wiki_url}
>
<div className="flex items-center gap-3">
{entity.image_path && (
<img
src={entity.image_path}
alt=""
className="w-24 h-24 rounded-full object-cover border-2 border-white shadow-md mx-auto"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
)}
<span>{entity.valor}</span>
</div>
<span className="font-medium text-blue-600 dark:text-blue-300 hover:underline transition-colors">
{entity.valor}
</span>
</WikiTooltip>
</td>
<td className="px-4 py-3 hidden md:table-cell">
<span className="text-xs text-gray-500 dark:text-gray-400">
#{entity.count} menciones
</span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${tipoInfo.color}`}>
{tipoInfo.label}
</span>
</td>
<td className="px-4 py-3 text-right">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-sm font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{entity.count}
</span>
</td>
<td className="px-4 py-3 text-center">
<div className="flex flex-col items-center gap-1">
<div className="flex items-center justify-center gap-1">
<button
onClick={() => openNews(entity)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Ver noticias donde se menciona"
>
🔍 Buscar
</button>
<button
onClick={() => openConfig(entity)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Configurar entidad"
>
Configurar
</button>
</div>
<div className="flex flex-wrap items-center justify-center gap-1">
{TIMELINE_WINDOWS.map((win) => (
<button
key={win.key}
onClick={() => openTimeline(entity, win)}
className="inline-flex items-center px-1.5 py-0.5 text-[10px] rounded bg-blue-600 text-white hover:bg-blue-700 transition-colors"
title={win.title}
>
{win.label}
</button>
))}
</div>
<span>{entity.count}</span>
</div>
</div>
{isExpanded && (
<div className="p-3 pt-0">
<div className="flex items-center justify-between gap-2">
<button
onClick={() => openNews(entity)}
className="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Ver noticias donde se menciona"
>
🔍 Ver noticias
</button>
<button
onClick={() => openConfig(entity)}
className="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Configurar entidad"
>
Configurar
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
<div className="flex flex-wrap items-center justify-center gap-1">
{TIMELINE_WINDOWS.map((win) => (
<button
key={win.key}
onClick={() => openTimeline(entity, win)}
className="inline-flex items-center px-1.5 py-0.5 text-[10px] rounded bg-blue-600 text-white hover:bg-blue-700 transition-colors"
title={win.title}
>
{win.label}
</button>
))}
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)}

View file

@ -3,6 +3,7 @@ import { useSearchParams, Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { apiService, api, News, Category, Country } from '../services/api'
import { Search as SearchIcon, Filter } from 'lucide-react'
import { SkeletonNewsList, NoResultsFound, NoSearchResults } from '../components/ui'
export function Search() {
const [searchParams, setSearchParams] = useSearchParams()
@ -132,13 +133,14 @@ export function Search() {
<p className="text-gray-600 mb-4">{data.total} resultados encontrados</p>
)}
{isLoading && (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
)}
{data?.news && data.news.length > 0 && (
{isLoading ? (
<SkeletonNewsList count={6} />
) : data?.news?.length === 0 ? (
<NoResultsFound
query={q}
onClearFilters={clearFilters}
/>
) : data?.news && data.news.length > 0 ? (
<div className="space-y-4">
{data.news.map((news: News) => (
<Link key={news.id} to={`/news/${news.id}`} className="card p-4 block hover:shadow-md transition-shadow">
@ -151,14 +153,8 @@ export function Search() {
</Link>
))}
</div>
)}
{(!q && !lang && !categoria && !pais) && (
<p className="text-center text-gray-500 mt-8">Introduce una búsqueda o selecciona filtros</p>
)}
{q && !isLoading && data?.news?.length === 0 && (
<p className="text-center text-gray-500 mt-8">No se encontraron resultados</p>
) : (
<NoSearchResults query={q} onSearch={clearFilters} />
)}
</div>
)

1154
plan-app.md Normal file

File diff suppressed because it is too large Load diff

269
plan-mejora.md Normal file
View file

@ -0,0 +1,269 @@
# Plan de Mejora - RSS2 Application
## Análisis Complejo
Se ha realizado un análisis exhaustivo identificando **35 problemas** en total, clasificados a partir de bugs.md y revisión de código:
- **6 Errores Críticos** (seguridad/estabilidad)
- **10 Inconsistencias** (funcionalidad)
- **5 Warnings de Arquitectura** (mantenibilidad)
- **14 Problemas Adicionales** (UI/funcionalidad/performance)
---
## ⚠️ Problemas Adicionales Detectados (Nuevos)
### 🔴 Críticos (2 nuevos)
#### A. Sin Validación de Entrada en Endpoints Públicos
- **Archivos:** `handlers/feed.go`, `handlers/news.go`, `handlers/search.go`
- **Descripción:** Endpoints aceptan parámetros sin validación estricta (ej. `page`, `per_page` sin límites duros, SQL injection risk en `fmt.Sprintf`)
- **Impacto:** Posible DoS via parámetros grandes, inyección SQL si cambian queries
- **Acción:** Usar validator library (`go-playground/validator/v10`), validar y sanitizar todos los inputs
#### B. Transacciones BD sin Manejo de Errores Adecuado
- **Archivos:** `handlers/feed.go:559-592` (ImportFeeds)
- **Descripción:** `SAVEPOINT`/`ROLLBACK TO SAVEPOINT`/`RELEASE SAVEPOINT` sin verificar errores en cada paso
- **Impacto:** Transacciones parcialmente aplicadas si falla a mitad
- **Acción:** Verificar error en cada `Exec`, usar `tx.Rollback()` en defer
### 🟡 Alta Priority (3 nuevos)
#### C. N+1 Query Problem en Entity Loading
- **Archivos:** `handlers/news.go:314-335` (GetNewsByID)
- **Descripción:** Query separada para entities por cada noticia
- **Impacto:** Latencia alta al cargar detalle de noticia
- **Acción:** JOIN en query principal o batch loading
#### D. Pool de Conexiones Sin Configuración Óptima
- **Archivos:** `backend/internal/db/postgres.go`
- **Descripción:** Pool creado con defaults, sin `MaxConns`, `MinConns`, `MaxConnLifetime`
- **Impacto:** Agotamiento de conexiones bajo carga, conexiones zombie
- **Acción:** Configurar pool size según cores/load, agregar health checks periódicos
#### E. Falta de Índices BD en Tablas Críticas
- **Archivos:** `init-db/` + `cmd/server/main.go` (initDB)
- **Descripción:** Tablas `noticias`, `traducciones`, `tags_noticia` sin índices en FKs y columnas de filtro
- **Impacto:** Queries lentas en producción con datos reales
- **Acción:** Añadir índices en `noticias.fecha`, `noticias.fuente_nombre`, `traducciones.noticia_id+lang_to`, `tags_noticia.noticia_id+tag_id`
#### F. Workers Python Sin Graceful Shutdown
- **Ubicación:** `workers/*.py`
- **Descripción:** Workers no manejan SIGTERM/SIGINT, terminan abruptamente
- **Impacto:** Tareas a medio procesar, estados inconsistentes
- **Acción:** Implementar signal handling, graceful shutdown con timeout
### 🟢 Media Priority (2 nuevos)
#### G. Cache Inconsistente / Sin Cache Strategy
- **Ubicación:** `backend/internal/cache/redis.go` + handlers
- **Descripción:** Redis disponible pero uso esporádico, sin TTL estándar, sin invalidación
- **Impacto:** Queries repetidas costosas, datos stale
- **Acción:** Definir cache keys + TTL estándar, invalidar en writes, usar en feeds/categorías/paises
#### H. Logging Estructurado Ausente
- **Ubicación:** Todo el backend
- **Descripción:** Solo `fmt.Println`/`log.Printf`, sin niveles, sin JSON, sin correlation IDs
- **Impacto:** Debugging difícil en producción
- **Acción:** Migrar a `zerolog` o `zap` con JSON output, request IDs
### 🔵 Baja Priority (2 nuevos)
#### I. Documentación API (OpenAPI/Swagger) Ausente
- **Ubicación:** Backend handlers
- **Impacto:** Frontend y consumers no tienen spec autogenerada
- **Acción:** Añadir anotaciones `swaggo/swag` o `go-swagger`, generar `/swagger` endpoint
#### J. Secrets en Variables Entorno Sin Validación
- **Ubicación:** `config/config.go` + docker-compose
- **Descripción:** Variables como `DB_PASS`, `REDIS_PASSWORD`, `SECRET_KEY` sin validar presencia/formato
- **Impacto:** Fallos en runtime confusos si faltan
- **Acción:** Validar todas las vars requeridas al startup, fallar rápido con mensaje claro
---
## 🔴 Crítico Priority (Seguridad y Estabilidad)
### 1. Rate Limiting No Implementado
- **Archivo:** `backend/internal/middleware/auth.go:104-108`
- **Descripción:** Función vacía que no limita requests
- **Impacto:** Vulnerable a ataques DoS y fuerza bruta
- **Acción:** Implementar usando `golang.org/x/time/rate`
### 2. CORS Demasiado Permisivo
- **Archivo:** `backend/internal/middleware/auth.go:77-90`
- **Descripción:** `Access-Control-Allow-Origin: *`
- **Impacto:** CSRF, exposición de datos a orígenes maliciosos
- **Acción:** Configurar orígenes permitidos desde variable de entorno
### 3. Secret Key Por Defecto Hardcodeada
- **Archivo:** `backend/internal/config/config.go:32`
- **Descripción:** `SecretKey: getEnv("SECRET_KEY", "change-this-secret-key")`
- **Impacto:** Compromiso total de autenticación si no se configura
- **Acción:** Hacer `SECRET_KEY` obligatoria, panic si no está set
### 4. Columna `role` en Users Sin Mapeo en Modelo
- **Archivos:** `models/models.go:86-94`, `cmd/server/main.go:41-48`
- **Descripción:** Columna `role` en BD pero no en struct User
- **Impacto:** Confusión entre `role` y `is_admin`
- **Acción:** Añadir campo `Role` a modelo User o remover columna BD
### 5. LoggerMiddleware No Funcional
- **Archivo:** `middleware/auth.go:93-101`
- **Descripción:** `c.Next()` sin logging de errores
- **Impacto:** Errores van sin registrar
- **Acción:** Implementar logging real de respuestas >= 400
### 6. Traducción Idioma Hardcodeado 'es'
- **Archivo:** `handlers/news.go:75,96`
- **Descripción:** `t.lang_to = 'es'` en múltiples queries
- **Impacto:** No configurable a otros idiomas
- **Acción:** Pasar idioma como parámetro o configuración
---
## 🟡 Alta Priority (Funcionalidad y Estabilidad)
### 7. Discovery HTML Feed Finder Vacio
- **Archivo:** `backend/cmd/discovery/main.go:129-133`
- **Descripción:** `findFeedLinksInHTML` retorna `[]string{}, nil`
- **Impacto:** No detecta feeds en HTML, solo URLsdirectas
- **Acción:** Implementar con `goquery` para parsear HTML
### 8. Mapeo de Campos DB vs Modelo Inconsistente
- **Archivo:** `search.go` vs `models/models.go:7-19`
- **Descripción:** Columnas `titulo`/`resumen`/`imagen`/`fecha` vs `Title`/`Summary`/`ImageURL`/`PublishedAt`
- **Impacto:** Errores de serialización potenciales
- **Acción:** Estandizar nombres en toda la aplicación
### 9. No Hay Tests Unitarios
- **Ubicación:** Todo el backend
- **Impacto:** Cambios pueden romper funcionalidad sin detección
- **Acción:** Crear tests para handlers críticos (auth, news, feeds)
### 10. Inicialización BD Duplicada
- **Archivos:** `db.Connect()` vs `workers.Connect()`
- **Impacto:** Piscs de conexión separadas, posible agotamiento
- **Acción:** Consolidar en una sola conexión/pool
### 11. Parámetros Query Inconsistentes
- **Descripción:** Algunos usan `category_id` vs `categoria_id`
- **Impacto:** Confusión en filtros
- **Acción:** Unificar nomenclatura (preferir `category_id` en inglés)
### 12. API Field Naming Inconsistente
- **Archivo:** `frontend/src/services/api.ts`
- **Descripción:** Interface `News` usa camelCase, pero respuestas van snake_case
- **Impacto:** Datos no se muestran correctamente
- **Acción:** Arreglar mapping en api.ts o estandarizar respuestas Go
---
## 🟢 Media Priority (Calidad y Mantenimiento)
### 13. No Hay Health Checks en Workers
- **Ubicación:** Todos los workers Python/Go
- **Impacto:** Dificultad para monitorear salud de workers
- **Acción:** Implementar endpoint `/health` en cada worker
### 14. Structs Duplicadas Mismo Recurso
- **Archivo:** `handlers/news.go` vs `models/models.go:News`
- **Descripción:** `NewsResponse` diferente a `models.News`
- **Impacto:** Mantenimiento duplicado
- **Acción:** Unificar o mantener mapping explícito
### 15. Query Params Inconsistentes (category_id vs categoria_id)
- **Descripción:** Mix de inglés y español en handlers
- **Impacto:** Confusión para usuarios de la API
- **Acción:** Definir estándar y aplicarlo en todos lados
### 16. Duplicación Config DB
- **Descripción:** API usa `DATABASE_URL`, workers usan `DB_HOST`/`DB_PORT` etc.
- **Impacto:** Configuración fragmentada
- **Acción:** Unificar variables de entorno
---
## 🔵 Baja Priority (UX y Refactoring)
### 17. Frontend API Field Mapping Fixes
- **Archivo:** `frontend/src/services/api.ts`
- **Impacto:** Datos se muestran incorrectamente o undefined
- **Acción:** Revisar y corregir tipos de interfaz
### 18. Mejorar Estados de Error en UI
- **Ubicación:** Todas las páginas frontend
- **Impacto:** UX pobre cuando fallan requests
- **Acción:** Mensajes de error claros y acciones de recuperación
### 19. Skeleton Loaders en lugar de Spinners
- **Ubicación:** Componente NewsList, News detail, etc.
- **Impacto:** Perceived performance mejorada
- **Acción:** Implementar componentes skeleton con Tailwind
### 20. Diseño Responsivo Mejorado
- **Ubicación:** Grid de noticias, selectores, formularios
- **Impacto:** Mejor experiencia móvil/tablet
- **Acción:** Refinar clases Tailwind, breakpoints
### 21. Tipografía y Esquema de Color Consistente
- **Ubicación:** Todo el frontend
- **Impacto:** Aspecto amateur, inconsistencia visual
- **Acción:** Definir palette principal, usar consistentemente
### 22. Micro-interacciones y Transiciones Más Suaves
- **Ubicación:** Hover effects, focus states, button animations
- **Impacto:** Experiencia más pulida y profesional
- **Acción:** Agregar transitions, hover: clases, focus-visible
### 23. Mejoras de Accesibilidad
- **Ubicación:** Componentes form, links, imágenes
- **Impacto:** Cumplimiento WCAG, mejor usabilidad
- **Acción:** ARIA labels, contrast ratios, focus outlines, tabindex
### 24. Estados Vacios Mejorados
- **Ubicación:** Páginas sin resultados, sin noticias, estados iniciales
- **Impacto:** UX cuando no hay datos que mostrar
- **Acción:** Mensajes descriptivos, ilustraciones, acciones sugeridas
### 25. Skeletons de Carga por Componente
- **Ubicación:** News cards, feeds lists, entity lists
- **Impacto:** Indicar progreso mejor que spinners puros
- **Acción:** Implementar skeleton grids con dimensiones reales
---
## Resumen Ejecutivo
| Prioridad | Cantidad | Enfoque |
|-----------|----------|---------|
| 🔴 Crítico | 8 | Seguridad, estabilidad, validación |
| 🟡 Alta | 10 | Funcionalidad, bugs, performance |
| 🟢 Media | 8 | Mantenimiento, calidad, observabilidad |
| 🔵 Baja | 8 | UX, refinamientos, documentación |
**Total: 35 problemas**
**Primeros pasos recomendados (Sprint 1 - Seguridad + Estabilidad):**
1. Rate limiting + CORS + Secret key (seguridad inmediata)
2. Validación de entrada en endpoints públicos
3. Transacciones BD con manejo de errores correcto
4. Índices BD en tablas críticas
**Sprint 2 - Funcionalidad + Performance:**
5. Arreglar naming conventions (consistencia en todo)
6. Pool de conexiones optimizado + health checks
7. N+1 queries en entity loading
8. Cache strategy + invalidación
**Sprint 3 - Calidad + Observabilidad:**
9. Logging estructurado (zerolog/zap)
10. Tests unitarios para handlers críticos
11. Graceful shutdown en workers
12. Health checks en todos los workers
**Sprint 4 - UX + Documentación:**
13. OpenAPI/Swagger docs
14. Skeleton loaders + estados vacíos
15. Accesibilidad + micro-interacciones
16. Traducción idioma configurable

25
translate-worker Executable file
View file

@ -0,0 +1,25 @@
#!/data/data/com.termux/files/usr/bin/sh
# ==============================================================================
# translate-worker - Worker de traducción MINIMO para Termux
# ==============================================================================
# Ejecuta el worker que se conecta al servidor y reenvía jobs de traducción.
# Primera vez pide API key, luego funciona automáticamente.
#==============================================================================
export WORKER_NAME="${WORKER_NAME:-termux-$(date +%s)}"
# Pedir API key si no está puesta
if [ -z "$RSS2_API_KEY" ]; then
echo "🔑 RSS2 Translation Worker"
echo "========================="
read -p "API key: " RSS2_API_KEY
export RSS2_API_KEY
fi
echo "🚀 Iniciando worker de traducción mínima..."
echo " Worker: $WORKER_NAME"
echo " Modo: Reenvío de jobs al servidor"
echo ""
# Ejecutar worker Python mínimo
exec python3 /home/x/rss2/translate_worker_termux.py "$@"

133
translate_worker_termux.py Executable file
View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
Worker de traducción MINIMO para Termux Android.
Objetivo único: Recibir jobs de traducción del servidor y enviar resultados de vuelta.
No requiere modelo ML para conectarse y reportar status.
"""
import os
import sys
import json
import logging
import readline
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
LOG = logging.getLogger("minimal-worker")
# ==========================================================
# Config - SOLO API key necesaria
# ==========================================================
WORKER_NAME = os.environ.get("WORKER_NAME", f"termux-{os.getpid()}")
WORKER_SERVER = os.environ.get("WORKER_SERVER", "ws://10.0.2.2:8080/ws/worker")
# API key: pedir si no está puesta
if not os.environ.get("RSS2_API_KEY"):
api_key = input("🔑 API key de RSS2: ").strip()
if not api_key:
print("❌ Key requerida. Saliendo.")
sys.exit(1)
os.environ["RSS2_API_KEY"] = api_key
WORKER_API_KEY = os.environ["RSS2_API_KEY"]
# Código de idiomas simples
LANG_MAP = {
"en": "eng_Latn", "es": "spa_Latn", "fr": "fra_Latn", "de": "deu_Latn",
"it": "ita_Latn", "pt": "por_Latn", "nl": "nld_Latn", "sv": "swe_Latn",
"da": "dan_Latn", "fi": "fin_Latn", "no": "nob_Latn",
"pl": "pol_Latn", "cs": "ces_Latn", "sk": "slk_Latn",
"sl": "slv_Latn", "hu": "hun_Latn", "ro": "ron_Latn",
"el": "ell_Grek", "ru": "rus_Cyrl", "uk": "ukr_Cyrl",
"tr": "tur_Latn", "ar": "arb_Arab", "fa": "pes_Arab",
"he": "heb_Hebr", "zh": "zho_Hans", "ja": "jpn_Jpan",
"ko": "kor_Hang", "vi": "vie_Latn",
}
# Estado simple - solo conecta y reenvía
connected = False
jobs_processed = 0
def on_open(ws):
global connected
connected = True
LOG.info(f"📡 Conectado como {WORKER_NAME}")
ws.send(json.dumps({
"type": "register",
"worker_name": WORKER_NAME
}))
def on_message(ws, message):
global jobs_processed
try:
msg = json.loads(message)
except:
return
if msg.get("type") == "job":
job = msg.get("job", {})
result = process_job(job)
ws.send(json.dumps({
"type": "result",
"result": result
}))
jobs_processed += 1
if jobs_processed % 10 == 0:
LOG.info(f"📊 Procesados {jobs_processed} jobs")
def process_job(job):
"""Procesa un job de traducción - mínimo esfuerzo."""
job_id = job.get("id", "unknown")
lang_from = job.get("lang_from", "en")
lang_to = job.get("lang_to", "es")
title = job.get("title", "")
summary = job.get("summary", "")
# Traducción "mínima": si mismoidioma, pas-through
# Si tiene modelo, traduciría aquí; si no, devolvemos original
# Marcar resultado
result = {
"job_id": job_id,
"title_orig": title[:50] if title else "",
"summary_orig": summary[:50] if summary else "",
"lang_from": lang_from,
"lang_to": lang_to,
"title_trad": title, # Pas-through si sin modelo
"summary_trad": summary,
"translated": False, # Flag: ¿realmente tradujimos?
"error": ""
}
return result
def on_error(ws, error):
LOG.error(f"WS error: {error}")
def on_close(ws, *args):
LOG.warning(f"Conexión cerrada")
global connected
connected = False
def connect():
"""Conectar WebSocket al servidor."""
import websocket
ws_url = f"ws://localhost:8080/ws/worker?api_key={WORKER_API_KEY}"
ws = websocket.WebSocketApp(
ws_url,
on_open=on_open,
on_message=on_message,
on_error=on_error,
on_close=on_close,
)
ws.run_forever(ping_interval=15, ping_timeout=5)
def main():
global connected
LOG.info(f"Worker mínimo starting: {WORKER_NAME}")
LOG.info(f"Conectando a: ws://localhost:8080/ws/worker")
# Intentar conectar (se reintenta solo si se corta)
connect()
if __name__ == "__main__":
main()

View file

@ -590,7 +590,7 @@ def fetch_pending_translations(conn):
WHERE t.lang_to = %s
AND (t.titulo_trad IS NULL OR t.resumen_trad IS NULL)
AND (t.locked_at IS NULL OR t.locked_at < NOW() - INTERVAL '10 minutes')
ORDER BY n.fecha ASC
ORDER BY n.fecha DESC
LIMIT %s
FOR UPDATE SKIP LOCKED
""",

View file

@ -64,7 +64,7 @@ def create_translation_jobs(conn):
SELECT 1 FROM traducciones t
WHERE t.noticia_id = n.id AND t.lang_to = %s
)
ORDER BY n.fecha ASC
ORDER BY n.fecha DESC
LIMIT %s
ON CONFLICT (noticia_id, lang_to) DO NOTHING
RETURNING noticia_id