From a01050bf85c2139dc0ffd341857ba554684f0ae1 Mon Sep 17 00:00:00 2001 From: jlimolina Date: Fri, 28 Aug 2026 21:10:02 +0200 Subject: [PATCH] cambios --- backend/cmd/discovery/main.go | 45 +- backend/cmd/qdrant/main.go | 70 +- backend/cmd/related/main.go | 44 +- backend/cmd/scraper/main.go | 76 +- backend/cmd/server/main.go | 80 +- backend/cmd/topics/main.go | 50 +- backend/cmd/wiki_worker/main.go | 84 +- backend/docs/docs.go | 43 + backend/go.mod | 25 +- backend/go.sum | 74 +- backend/internal/auth/jwt.go | 14 + backend/internal/cache/redis.go | 111 +- backend/internal/config/config.go | 9 +- backend/internal/db/postgres.go | 36 +- backend/internal/handlers/admin.go | 106 +- backend/internal/handlers/alerts.go | 78 +- backend/internal/handlers/auth.go | 41 + backend/internal/handlers/auth_test.go | 40 +- backend/internal/handlers/feed.go | 107 +- backend/internal/handlers/feed_test.go | 35 + backend/internal/handlers/news.go | 299 +++-- backend/internal/handlers/news_test.go | 38 + backend/internal/handlers/search.go | 141 ++- backend/internal/handlers/search_test.go | 35 + backend/internal/handlers/validation.go | 14 + backend/internal/logger/logger.go | 60 + backend/internal/middleware/auth.go | 105 +- backend/internal/models/models.go | 109 +- backend/internal/workers/db.go | 39 +- docker-compose.yml | 6 +- entity_config.json | 12 +- frontend/src/components/layout/Layout.tsx | 4 +- frontend/src/components/ui/EmptyState.tsx | 166 +++ frontend/src/components/ui/Skeleton.tsx | 110 ++ frontend/src/components/ui/WikiTooltip.tsx | 44 +- frontend/src/components/ui/index.ts | 3 + frontend/src/pages/Alertas.tsx | 115 +- frontend/src/pages/Home.tsx | 16 +- frontend/src/pages/Populares.tsx | 193 ++-- frontend/src/pages/Search.tsx | 26 +- plan-app.md | 1154 ++++++++++++++++++++ plan-mejora.md | 269 +++++ translate-worker | 25 + translate_worker_termux.py | 133 +++ workers/ctranslator_worker.py | 2 +- workers/translation_scheduler.py | 2 +- 46 files changed, 3636 insertions(+), 652 deletions(-) create mode 100644 backend/docs/docs.go create mode 100644 backend/internal/handlers/feed_test.go create mode 100644 backend/internal/handlers/news_test.go create mode 100644 backend/internal/handlers/search_test.go create mode 100644 backend/internal/handlers/validation.go create mode 100644 backend/internal/logger/logger.go create mode 100644 frontend/src/components/ui/EmptyState.tsx create mode 100644 frontend/src/components/ui/Skeleton.tsx create mode 100644 frontend/src/components/ui/index.ts create mode 100644 plan-app.md create mode 100644 plan-mejora.md create mode 100755 translate-worker create mode 100755 translate_worker_termux.py diff --git a/backend/cmd/discovery/main.go b/backend/cmd/discovery/main.go index 2f77d76..92970f5 100644 --- a/backend/cmd/discovery/main.go +++ b/backend/cmd/discovery/main.go @@ -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) diff --git a/backend/cmd/qdrant/main.go b/backend/cmd/qdrant/main.go index bf9f054..70accb8 100644 --- a/backend/cmd/qdrant/main.go +++ b/backend/cmd/qdrant/main.go @@ -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) } } } diff --git a/backend/cmd/related/main.go b/backend/cmd/related/main.go index f1f1be5..20643e2 100644 --- a/backend/cmd/related/main.go +++ b/backend/cmd/related/main.go @@ -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) } } } diff --git a/backend/cmd/scraper/main.go b/backend/cmd/scraper/main.go index 8d372ea..7e90f19 100644 --- a/backend/cmd/scraper/main.go +++ b/backend/cmd/scraper/main.go @@ -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) } } diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 81caed4..bf6f6b5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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...") } diff --git a/backend/cmd/topics/main.go b/backend/cmd/topics/main.go index a9e83c0..7b958fa 100644 --- a/backend/cmd/topics/main.go +++ b/backend/cmd/topics/main.go @@ -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 { diff --git a/backend/cmd/wiki_worker/main.go b/backend/cmd/wiki_worker/main.go index f6298b1..c98b799 100644 --- a/backend/cmd/wiki_worker/main.go +++ b/backend/cmd/wiki_worker/main.go @@ -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) + } + } } } } diff --git a/backend/docs/docs.go b/backend/docs/docs.go new file mode 100644 index 0000000..c07ab67 --- /dev/null +++ b/backend/docs/docs.go @@ -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 \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index bd2af34..1d39e9e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 ) diff --git a/backend/go.sum b/backend/go.sum index bfa3dc0..95b9d58 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index c00db91..528920a 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -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 +} diff --git a/backend/internal/cache/redis.go b/backend/internal/cache/redis.go index 43c0b2f..c1cdc46 100644 --- a/backend/internal/cache/redis.go +++ b/backend/internal/cache/redis.go @@ -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 +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f218100..343c15b 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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, } } diff --git a/backend/internal/db/postgres.go b/backend/internal/db/postgres.go index 1b1a822..dcbd85f 100644 --- a/backend/internal/db/postgres.go +++ b/backend/internal/db/postgres.go @@ -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 +} diff --git a/backend/internal/handlers/admin.go b/backend/internal/handlers/admin.go index 538cf5c..d9fc71c 100644 --- a/backend/internal/handlers/admin.go +++ b/backend/internal/handlers/admin.go @@ -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 diff --git a/backend/internal/handlers/alerts.go b/backend/internal/handlers/alerts.go index 7a5977a..e9d985e 100644 --- a/backend/internal/handlers/alerts.go +++ b/backend/internal/handlers/alerts.go @@ -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 { diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go index 6950200..d5dbc15 100644 --- a/backend/internal/handlers/auth.go +++ b/backend/internal/handlers/auth.go @@ -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 { diff --git a/backend/internal/handlers/auth_test.go b/backend/internal/handlers/auth_test.go index 7586995..fd512eb 100644 --- a/backend/internal/handlers/auth_test.go +++ b/backend/internal/handlers/auth_test.go @@ -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) diff --git a/backend/internal/handlers/feed.go b/backend/internal/handlers/feed.go index ad7afc3..799550c 100644 --- a/backend/internal/handlers/feed.go +++ b/backend/internal/handlers/feed.go @@ -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, diff --git a/backend/internal/handlers/feed_test.go b/backend/internal/handlers/feed_test.go new file mode 100644 index 0000000..8f888f1 --- /dev/null +++ b/backend/internal/handlers/feed_test.go @@ -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) + } + }) + } +} \ No newline at end of file diff --git a/backend/internal/handlers/news.go b/backend/internal/handlers/news.go index 374d0db..e9a0bfb 100644 --- a/backend/internal/handlers/news.go +++ b/backend/internal/handlers/news.go @@ -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 diff --git a/backend/internal/handlers/news_test.go b/backend/internal/handlers/news_test.go new file mode 100644 index 0000000..4fc7108 --- /dev/null +++ b/backend/internal/handlers/news_test.go @@ -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) + } + }) + } +} \ No newline at end of file diff --git a/backend/internal/handlers/search.go b/backend/internal/handlers/search.go index ef5314f..edc7fa1 100644 --- a/backend/internal/handlers/search.go +++ b/backend/internal/handlers/search.go @@ -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) } diff --git a/backend/internal/handlers/search_test.go b/backend/internal/handlers/search_test.go new file mode 100644 index 0000000..6995814 --- /dev/null +++ b/backend/internal/handlers/search_test.go @@ -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) + } + }) + } +} \ No newline at end of file diff --git a/backend/internal/handlers/validation.go b/backend/internal/handlers/validation.go new file mode 100644 index 0000000..fc8f66e --- /dev/null +++ b/backend/internal/handlers/validation.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go new file mode 100644 index 0000000..239d89c --- /dev/null +++ b/backend/internal/logger/logger.go @@ -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() +} \ No newline at end of file diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 4c35c35..92d8e2b 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -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() } } diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 5653503..d118603 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -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"` } diff --git a/backend/internal/workers/db.go b/backend/internal/workers/db.go index 7f01625..fe899ae 100644 --- a/backend/internal/workers/db.go +++ b/backend/internal/workers/db.go @@ -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 { diff --git a/docker-compose.yml b/docker-compose.yml index 4c9ea80..db6b4f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 # ================================================================================== diff --git a/entity_config.json b/entity_config.json index cbcaca5..b028678 100644 --- a/entity_config.json +++ b/entity_config.json @@ -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": [ diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index f203767..465279b 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -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() { diff --git a/frontend/src/components/ui/EmptyState.tsx b/frontend/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..71f8d6c --- /dev/null +++ b/frontend/src/components/ui/EmptyState.tsx @@ -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 ( +
+
+ +
+

{title}

+

{description}

+ {action && ( + + )} +
+ ) +} + +export function NoResultsFound({ + query, + onClearFilters +}: { + query?: string + onClearFilters?: () => void +}) { + return ( + + ) +} + +export function NoNewsYet({ + onAddFeed +}: { + onAddFeed?: () => void +}) { + return ( + + ) +} + +export function NoFeedsConfigured({ + onAddFeed +}: { + onAddFeed?: () => void +}) { + return ( + + ) +} + +export function NoSearchResults({ + query, + onSearch +}: { + query?: string + onSearch?: () => void +}) { + return ( + + ) +} + +export function OfflineState({ + onRetry +}: { + onRetry?: () => void +}) { + return ( + + ) +} + +export function ErrorState({ + message = 'Ha ocurrido un error inesperado', + onRetry +}: { + message?: string + onRetry?: () => void +}) { + return ( + + ) +} \ No newline at end of file diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..30cb580 --- /dev/null +++ b/frontend/src/components/ui/Skeleton.tsx @@ -0,0 +1,110 @@ +export function SkeletonNewsCard() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} + +interface SkeletonNewsListProps { + count?: number +} + +export function SkeletonNewsList({ count = 6 }: SkeletonNewsListProps) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ) +} + +export function SkeletonFeedCard() { + return ( +
+
+
+
+
+
+
+
+
+
+ ) +} + +interface SkeletonFeedListProps { + count?: number +} + +export function SkeletonFeedList({ count = 5 }: SkeletonFeedListProps) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ) +} + +export function SkeletonEntityCard() { + return ( +
+
+
+
+
+
+
+
+
+ ) +} + +interface SkeletonEntityListProps { + count?: number +} + +export function SkeletonEntityList({ count = 4 }: SkeletonEntityListProps) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ) +} + +export function SkeletonStats() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) +} + +export function SkeletonChart() { + return ( +
+
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ui/WikiTooltip.tsx b/frontend/src/components/ui/WikiTooltip.tsx index 96624a7..7484cf8 100644 --- a/frontend/src/components/ui/WikiTooltip.tsx +++ b/frontend/src/components/ui/WikiTooltip.tsx @@ -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 {children}; } + const tooltipRef = useRef(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 ( -
- +
+ {children} -
+
); diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 0000000..7a1a2dd --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -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' \ No newline at end of file diff --git a/frontend/src/pages/Alertas.tsx b/frontend/src/pages/Alertas.tsx index 9f5853b..10fe7c6 100644 --- a/frontend/src/pages/Alertas.tsx +++ b/frontend/src/pages/Alertas.tsx @@ -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 = { persona: 'Persona', @@ -15,6 +16,11 @@ export function Alertas() { const [nuevas, setNuevas] = useState(0) const [limit, setLimit] = useState(50) + const [newsModal, setNewsModal] = useState(null) + const [entityNews, setEntityNews] = useState([]) + 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 (
@@ -108,13 +134,17 @@ export function Alertas() { - {a.status === 'nueva' ? ( - - ) : ( - - )} + {a.status === 'nueva' && ( + + )} +
))} @@ -132,6 +162,75 @@ export function Alertas() { Mostrar {limit > 50 ? 'menos' : 'más'} ({limit})
+ + {/* Noticias relacionadas del concepto */} + {newsModal && ( +
setNewsModal(null)}> +
e.stopPropagation()} + > +
+
+

Noticias sobre "{newsModal.valor}"

+

+ {newsTotal} noticias encontradas · {TIPO_LABEL[newsModal.tipo] || newsModal.tipo} · {newsModal.periodo} +

+
+ +
+ +
+ {newsLoading ? ( +
Cargando...
+ ) : entityNews.length === 0 ? ( +
No hay noticias que mencionen a este concepto.
+ ) : ( +
    + {entityNews.map((n) => ( +
  • + 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" + > +
    + {n.imagen_url && ( + (e.currentTarget.style.display = 'none')} + /> + )} +
    +
    + {n.title_translated || n.titulo} +
    +

    + {n.summary_translated || n.resumen} +

    + {n.fecha && ( + + {new Date(n.fecha).toLocaleDateString('es-ES')} + + )} +
    +
    + +
  • + ))} +
+ )} +
+
+
+ )}
) } \ No newline at end of file diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index cac8c99..b567df2 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -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() {
{isLoading ? ( -
-
-
+ ) : error ? (
Error al cargar noticias +
+ ) : data?.news?.length === 0 ? ( + setSearchParams({ page: '1', q })} + /> ) : ( <>
diff --git a/frontend/src/pages/Populares.tsx b/frontend/src/pages/Populares.tsx index 39bcd95..5acff34 100644 --- a/frontend/src/pages/Populares.tsx +++ b/frontend/src/pages/Populares.tsx @@ -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(null) const [saving, setSaving] = useState(false) const [successMsg, setSuccessMsg] = useState('') + const [expandedEntities, setExpandedEntities] = useState>(new Set()) + const [allExpanded, setAllExpanded] = useState(false) // News-by-entity modal state const [newsModal, setNewsModal] = useState(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() {

🔥 Populares {selectedCountry ? `en ${selectedCountry.nombre}` : 'Global'}

+
+ + + +
- {/* Table */} + {/* Listado de entidades */} {loading ? (
Cargando...
) : entities.length === 0 ? (
No se encontraron entidades
) : (
- - - - - - - - - - - - {entities.map((entity, idx) => { - const tipoInfo = getTipoInfo(entity.tipo) - return ( - - - - - - - - ) - })} - -
#EntidadTipoMencionesConfig
{(page - 1) * 50 + idx + 1} +
+
+ 📊 {entities.length} entidades populares +
+ + +
+
+
+
+ {entities.map((entity, idx) => { + const isExpanded = allExpanded || expandedEntities.has(entity.valor) + const tipoInfo = getTipoInfo(entity.tipo) + return ( +
+
+
setExpandedEntities(p => p.toggle(entity.valor))}> -
- {entity.image_path && ( - (e.currentTarget.style.display = 'none')} - /> - )} - {entity.valor} -
+ + {entity.valor} +
-
+ + #{entity.count} menciones + + +
{tipoInfo.label} -
- - {entity.count} - - -
-
- - -
-
- {TIMELINE_WINDOWS.map((win) => ( - - ))} -
+ {entity.count} +
+ + {isExpanded && ( +
+
+ +
-
+
+ {TIMELINE_WINDOWS.map((win) => ( + + ))} +
+
+ )} +
+ ) + })} + )} diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index 5f739b2..f970964 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -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() {

{data.total} resultados encontrados

)} - {isLoading && ( -
-
-
- )} - - {data?.news && data.news.length > 0 && ( + {isLoading ? ( + + ) : data?.news?.length === 0 ? ( + + ) : data?.news && data.news.length > 0 ? (
{data.news.map((news: News) => ( @@ -151,14 +153,8 @@ export function Search() { ))}
- )} - - {(!q && !lang && !categoria && !pais) && ( -

Introduce una búsqueda o selecciona filtros

- )} - - {q && !isLoading && data?.news?.length === 0 && ( -

No se encontraron resultados

+ ) : ( + )} ) diff --git a/plan-app.md b/plan-app.md new file mode 100644 index 0000000..20c6ca8 --- /dev/null +++ b/plan-app.md @@ -0,0 +1,1154 @@ +# Plan de Migración de RSS2 a Aplicación Multiplataforma + +**Versión:** 1.0 +**Fecha:** 2026-08-14 +**Estado:** Propuesta de arquitectura y plan de migración + +--- + +## Tabla de Contenidos + +1. [Resumen Ejecutivo](#1-resumen-ejecutivo) +2. [Análisis Profundo del Código Actual](#2-análisis-profundo-del-código-actual) +3. [Objetivos y Requisitos de la Nueva Aplicación](#3-objetivos-y-requisitos-de-la-nueva-aplicación) +4. [Arquitectura Objetivo (Alternativas)](#4-arquitectura-objetivo-alternativas) +5. [Selección Tecnológica por Plataforma](#5-selección-tecnológica-por-plataforma) +6. [Estrategia de Datos y Sincronización](#6-estrategia-de-datos-y-sincronización) +7. [Estrategia de IA/ML en el Dispositivo](#7-estrategia-de-iaml-en-el-dispositivo) +8. [Núcleo Embebido (Embedded Backend)](#8-núcleo-embebido-embedded-backend) +9. [Rediseño de la API para Clientes Nativos](#9-rediseño-de-la-api-para-clientes-nativos) +10. [Migración del Frontend Paso a Paso](#10-migración-del-frontend-paso-a-paso) +11. [Empaquetado y Distribución por Plataforma](#11-empaquetado-y-distribución-por-plataforma) +12. [Infraestructura de Build y CI/CD](#12-infraestructura-de-build-y-cicd) +13. [Plan de Implementación por Fases](#13-plan-de-implementación-por-fases) +14. [Riesgos y Mitigaciones](#14-riesgos-y-mitigaciones) +15. [Estrategia de Testing y QA](#15-estrategia-de-testing-y-qa) +16. [Referencias y Documentación](#16-referencias-y-documentación) + +--- + +## 1. Resumen Ejecutivo + +RSS2 es actualmente una **aplicación web monolítica orquestada con Docker Compose** compuesta por más de 20 servicios interconectados: un frontend React SPA, un backend Go (API REST Gin + 6 workers de ingesta/enriquecimiento), 11 workers Python (traducción neuronal, embeddings, NER, clustering, detección de idioma, categorización LLM) y tres sistemas de almacenamiento (PostgreSQL, Redis, Qdrant). + +El objetivo de este plan es transformar este sistema en una **aplicación nativa instalable** en **Windows, Linux, Android e iOS**. Dada la enorme complejidad del stack actual (modelos ML de 600MB–1.3B, vector DB, colas, 20 servicios), la estrategia recomendada es una **arquitectura cliente–servidor con capacidades offline-first**: + +- **El "cerebro" del sistema** (ingesta, IA, almacenamiento) sigue corriendo como un servidor (Docker o binario nativo en un equipo potente). +- **Las apps nativas** (desktop y móviles) son clientes ricos con caché local SQLite, modo offline, notificaciones push y, en escritorios con GPU, capacidad de actuar como **workers remotos de traducción** (reutilizando la infraestructura WebSocket ya existente). + +La **clave del éxito** es el altísimo grado de reutilización del frontend React actual: la misma base de código puede empaquetarse con **Tauri** (Windows/Linux) y con **Capacitor** (Android/iOS), requiriendo únicamente abstracciones sobre las APIs del navegador. + +--- + +## 2. Análisis Profundo del Código Actual + +### 2.1 Arquitectura General + +``` + ┌────────────────────────────────────────────┐ + │ CLIENTE WEB (Navegador) │ + │ React 18 + Vite + Tailwind + React Query │ + └───────────────────┬────────────────────────┘ + │ HTTP/JSON /api + JWT + ▼ + ┌────────────────────────────────────────────┐ + │ nginx (puerto 8888) │ + │ Gateway + proxy inverso + estáticos SPA │ + └───────┬─────────────────────────┬──────────┘ + │ /api/ │ / (estáticos) + ▼ ▼ + ┌────────────────────┐ ┌────────────────────┐ + │ backend-go │ │ rss2_frontend │ + │ Gin REST API :8080│ │ React compilado │ + └───────┬──────┬─────┘ └────────────────────┘ + ┌─────────────────┘ └───────────────┬────────────────────┐ + ▼ ▼ ▼ + PostgreSQL 18 Redis 7 Qdrant + (esquema relacional) (caché/colas) (vector DB) + ▲ + │ Red interna de Docker Compose (backend network) + ┌──────────┴─────────────────────────────────────────────────────────────┐ + │ Workers Go Workers Python │ + │ ──────────── ────────────── │ + │ rss-ingestor-go langdetect_worker.py │ + │ scraper (deep scraping) ctranslator_worker.py (NLLB-200) │ + │ discovery (descubrir feeds) embeddings_worker.py (MiniLM) │ + │ wiki_worker (Wikipedia) ner_worker.py (spaCy es_core_news_lg) │ + │ topics (países/temas) cluster_worker.py │ + │ related (similitud coseno) translation_scheduler.py │ + │ qdrant (vectorización) llm_categorizer_worker.py (Ollama) │ + │ remote_translator_worker.py (WS GPU) │ + └────────────────────────────────────────────────────────────────────────┘ +``` + +**Topología de redes (docker-compose.yml):** +- Red `frontend`: nginx, rss2_frontend, backend-go (puente entre redes). +- Red `backend`: db, redis, ingestor, langdetect, scraper, discovery, wiki-worker, translator, translator-gpu, scheduler, embeddings, topics, related, qdrant, qdrant-worker, ner, cluster. +- Único puerto expuesto al exterior: **nginx :8888**. + +### 2.2 Inventario de Servicios (docker-compose.yml, 20 servicios) + +| Servicio | Imagen/Dockerfile | Rol | Recursos | +|---|---|---|---| +| `db` | postgres:18-alpine | Base de datos relacional | 2 CPU / 8G | +| `redis` | redis:7-alpine | Caché + colas (workers Python) | 768M | +| `rss-ingestor-go` | rss-ingestor-go/Dockerfile | Crawler RSS (100 workers) | 2 CPU / 2G | +| `langdetect` | Dockerfile | Detección de idioma | 0.5 CPU / 512M | +| `scraper` | Dockerfile.scraper | Extracción profunda de artículos | 1 CPU / 512M | +| `discovery` | Dockerfile.discovery | Descubrimiento de feeds | 1 CPU / 512M | +| `wiki-worker` | Dockerfile.wiki | Enriquecimiento Wikipedia | 0.5 CPU / 256M | +| `backend-go` | backend/Dockerfile | API REST principal | 2 CPU / 512M | +| `rss2_frontend` | frontend/Dockerfile | SPA React | 1 CPU / 512M | +| `nginx` | nginx:alpine | Gateway | — | +| `translator` | Dockerfile.translator | Traducción NLLB CPU | 4 CPU / 6G | +| `translator-gpu` | Dockerfile.translator | 2º worker CPU (nombre heredado) | 4 CPU / 6G | +| `translation-scheduler` | Dockerfile.scheduler | Crea trabajos de traducción | 0.5 CPU / 256M | +| `embeddings` | Dockerfile | Vectores MiniLM | 2 CPU / 6G | +| `topics` | Dockerfile.topics | Matcher países/temas | 1 CPU / 512M | +| `related` | Dockerfile.related | Noticias relacionadas | 1 CPU / 1G | +| `qdrant` | qdrant/qdrant:latest | Base vectorial | 4 CPU / 4G | +| `qdrant-worker` | Dockerfile.qdrant | Vectorización → Qdrant | 1 CPU / 1G | +| `ner` | Dockerfile | NER spaCy | 2 CPU / 2G | +| `cluster` | Dockerfile | Clustering de noticias | 2 CPU / 2G | + +> **Observación crítica para la migración:** el servicio `translator-gpu` NO usa CUDA; es un segundo worker CPU. La única capacidad GPU real es vía **workers remotos WebSocket** (`remote_translator_worker.py`). Esto es una ventaja enorme: la infraestructura para que un equipo externo (potencialmente una app de escritorio) traduzca vía WebSocket **ya existe y está probada**. + +### 2.3 Frontend (React + TypeScript + Vite) + +**Ubicación:** `frontend/src` — 26 archivos, ~4.300 líneas TS/TSX. + +**Stack:** React 18.2, TypeScript 5.3 (strict), Vite 5, Tailwind CSS 3.4, React Router 6, TanStack React Query 5, Axios, lucide-react (iconos), date-fns. + +**Estructura:** +- `main.tsx` → `QueryClientProvider` → `BrowserRouter` → `App`. +- `App.tsx`: llama a `GET /auth/check-first-user`; si es el primer usuario muestra `WelcomeWizard` (onboarding de 3 pasos), si no, el árbol de rutas envuelto en `Layout`. +- 18 páginas: `Home`, `News`, `Search`, `Feeds`, `Stats`, `Favorites`, `Account`, `Login`, `Populares` (732 líneas, la más compleja), `Analisis`, `Alertas`, `WelcomeWizard`, `AdminAliases`, `AdminUsers`, `AdminSettings`, `AdminWorkers`. +- 4 componentes reutilizables: `Layout`, `ProtectedRoute`, `LineChart` (SVG manual), `WikiTooltip`. +- `services/api.ts` (279 líneas): instancia Axios, interceptor de petición (inyecta JWT de `localStorage`), interceptor de respuesta (401 → redirige a `/login?expired=1`), tipos TS e `apiService` (22 métodos tipados) + exportación del cliente crudo `api`. + +**Dependencias de navegador que bloquean una migración nativa directa:** + +| API web actual | Uso | Sustitución nativa | +|---|---|---| +| `localStorage` (`token`, `user`, `favorites`) | Almacenar sesión y favoritos | SecureStorage/Keychain/Keyring (Electron/Tauri/Capacitor) + SQLite | +| `window.location.href` (redirect 401) | Forzar logout | Callback de sesión expirada → navegación interna | +| `window.confirm/prompt/alert` | Confirmar borrados, doble confirmación ("type SI"), etc. | Diálogos nativos (capas JS→Rust/ObjC/Swift/Kotlin) o modales React | +| `URL.createObjectURL` + `` | Descarga de CSV/ZIP/SQL | API de archivos nativa (`saveDialog` en Tauri/Electron, `Share`/filesystem en Capacitor) | +| `window.addEventListener('storage')` | Sync de auth entre pestañas | Eventos de sesión dentro de la misma app | +| `window.history` (BrowserRouter) | Navegación | `HashRouter`/`MemoryRouter` o navegación nativa | +| `window.URLSearchParams` | Parámetros de ruta | React Router compatible | +| `navigator`/Viewport | Responsividad | Adaptación a pantallas táctiles | + +**Deudas técnicas detectadas en el frontend (deben corregirse durante la migración):** +1. **Nav móvil rota:** `hidden sm:flex` sin hamburguesa → inutilizable en móvil (< 640px). +2. `WikiTooltip` solo responde a hover → sin soporte táctil. +3. Página `Favorites` usa solo `localStorage` (no sincronizada con backend, pese a existir la tabla `favoritos`). +4. `Account` no persiste cambios (guardado falso). +5. Botón `btn-danger` usado pero **no definido** en `index.css`. +6. Dos patrones de fetch inconsistentes: React Query vs `useEffect`+fetch manual (Populares, Alertas, Analisis, Admin*). +7. Dos clientes de API: `api` crudo vs `apiService` tipado. +8. Filtros de categoría/país en `Search` son "muertos" (no se envían al backend). +9. `clsx` y `tailwind-merge` instalados pero sin uso. +10. **No existe ni un solo test** pese a vitest configurado. +11. Sin code-splitting (`React.lazy` ausente); todo el bundle en un solo archivo. +12. `JWT_EXPIRATION` del backend ignorado; el token expira en 24h fijas sin refresh — **crítico para apps móviles**. + +### 2.4 Backend Go (API + Workers) + +**Módulo:** `github.com/rss2/backend`, Go 1.23, `gin v1.9.1`, `pgx/v5`, `go-redis/v9`, `golang-jwt/v5`, `gorilla/websocket`, `gofeed`, `goquery`. + +**Paquetes internos:** +- `internal/config` — `Load()` lee variables de entorno (sin archivos `.env`; todo lo inyecta Docker). +- `internal/db` — `pgxpool` (25 conns max), solo para la API (`DATABASE_URL`). +- `internal/cache` — Cliente Redis (helpers nunca usados por handlers; solo conexión). +- `internal/auth` — JWT HS256, expiración 24h **hardcodeada**. +- `internal/middleware` — `AuthRequired`, `AdminRequired`, CORS. `RateLimit` es un stub **no conectado**. +- `internal/models` — Tipos de dominio. ⚠️ `News.ID` es `int64` pero la DB usa `VARCHAR(32)` (MD5 de la URL). +- `internal/handlers` — auth, news, feed, search, alerts, admin, remote_worker, worker_ws. +- `internal/services/ml.go` — Clientes HTTP hacia Libretranslate/Ollama/spaCy; `SemanticSearch` es **stub que devuelve vacío**. +- `internal/workers/db.go` — Helper de conexión compartido por los 6 workers (usa `DB_*` env vars). + +**Arranque del servidor (`cmd/server/main.go`):** +1. `config.Load()` → 2. `db.Connect()` (fatal si falla) → 3. `initDB()` (migraciones idempotentes: crea `entity_aliases`, `config`, `alertas`, `remote_workers`; añade `role`; seed de `config`) → 4. `cache.Connect()` (no fatal) → 5. `services.Init()` → 6. router Gin → 7. CORS → 8. rutas → 9. `auth.SetJWTSecret()` → 10. `r.Run(":8080")` → 11. goroutines `StartJobAssigner()` (cada 5s) y `StartAlertScanner()` (cada 120 min) → 12. señal SIGINT/SIGTERM para shutdown graceful. + +**Workers Go (binarios independientes, todos con bucle `time.Ticker`):** + +| Worker | Loop | Función | +|---|---|---| +| `scraper` | 60s | Enriquece `noticias` con resumen corto (<200 chars) vía goquery; extrae artículos de `fuentes_url` (OpenGraph + selectores). | +| `discovery` | 900s | Detecta feeds en `fuentes_url`; crea `feeds` o `feeds_pending` (aprobación manual). | +| `wiki_worker` | 30s | Consulta la API REST de Wikipedia ES; descarga thumbnails (≤2MB) a `data/wiki_images`; rellena `wiki_summary`, `wiki_url`, `image_path`. | +| `topics` | 10s | Matchea keywords de `topics` contra noticias sin procesar; asigna `pais_id`. | +| `related` | 10s | Similitud coseno entre embeddings almacenados; rellena `related_noticias`. | +| `qdrant` | 30s | Toma `traducciones` sin vectorizar, parsea el embedding del array Postgres, hace upsert en colección `news_vectors` de Qdrant. | + +**WebSocket `/ws/worker` (`handlers/worker_ws.go`):** +- Protocolo existente y probado para workers remotos GPU. +- Auth por `api_key` (query o header `X-API-Key`) contra tabla `remote_workers`. +- Registro, heartbeat (10s), ping (30s), asignación de jobs con `FOR UPDATE SKIP LOCKED`, límite de 5 min en `assigned`, re-encolado tras desconexión. +- **El endpoint se registra fuera de `/api`** (en `/ws/worker`) y **no está proxied** por el nginx de producción — quirk a documentar. + +### 2.5 Workers Python + +**Dependencias (`requirements.txt`):** Flask, feedparser, APScheduler, psycopg2, transformers 4.43, ctranslate2, sentence-transformers 3.0.1, spaCy 3.7, sklearn, qdrant-client 1.11, redis, langdetect, newspaper3k, etc. + +| Worker | Modelo | Recursos | Comportamiento | +|---|---|---|---| +| `ctranslator_worker.py` | NLLB-200 distilled 600M (CT2, int8) | 4 CPU / 6G | Loop 30s; `FOR UPDATE SKIP LOCKED`; batch 128; cache Redis por título/cuerpo; trocea cuerpos en chunks de 900 chars; `executemany`; registra `translation_stats`. | +| `embeddings_worker.py` | sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 | 2 CPU / 6G | `model.encode(normalize_embeddings=True)`; upsert en `traduccion_embeddings`; sleep 5s idle. | +| `ner_worker.py` | spaCy `es_core_news_lg` | 2 CPU / 2G | Extrae PER/ORG/LOC → `tags` y `tags_noticia`. | +| `cluster_worker.py` | embeddings MiniLM + sklearn | 2 CPU / 2G | Agrupa noticias en `eventos` (umbral 0.35). | +| `langdetect_worker.py` | langdetect | 0.5 CPU | Detecta idioma → `noticias.lang`. | +| `llm_categorizer_worker.py` | Ollama/Mistral-7B | (externa) | Clasificación LLM (opcional). | +| `translation_scheduler.py` | — | 0.5 CPU | Crea filas `traducciones` pendientes. | +| `remote_translator_worker.py` | NLLB-200 (600M/1.3B) | GPU/CPU | Cliente WebSocket → servidor `/ws/worker`; reconnect exponencial; procesa jobs de traducción. **Este es el prototipo perfecto para el módulo "worker" de la app de escritorio.** | + +**Dependencia CTranslate2:** `libctranslate2.so` requiere `patchelf --clear-execstack` en Linux (ver `Dockerfile.remote-worker`), y torch==2.1.0 cu118. Esto condiciona el empaquetado nativo (ver §8 y §11). + +### 2.6 Base de Datos PostgreSQL + +**Esquema** (fuente: `init-db/00-complete-schema.sql` + 37 migraciones numeradas; se aplica al iniciar el contenedor, sin framework de migraciones Go). + +**Tablas principales (28):** +- **Núcleo:** `users`, `config`, `continentes`, `categorias`, `paises`, `feeds`, `fuentes_url`, `noticias`, `traducciones`. +- **Enriquecimiento:** `tags`, `tags_noticia`, `entity_aliases`, `entity_images`, `topics`, `news_topics`, `related_noticias`, `eventos`, `eventos_noticias`. +- **IA:** `traduccion_embeddings` (array `DOUBLE PRECISION[]`), vista `embeddings`. +- **Social:** `favoritos`, `search_history`, `user_search_tags`, `alertas`, `videos`, `video_parrillas`, `remote_workers`, `translation_stats`, `feeds_pending`. + +**Características PostgreSQL-específicas (problemas para un embebido SQLite):** +1. **PK de noticias = `VARCHAR(32)`** = MD5 hex de la URL. +2. Full-text en español: columnas `tsvector` (`tsv`, `search_vector_es`) con triggers (`to_tsvector('spanish', ...)`) + índices GIN. +3. `traduccion_embeddings.embedding` es un **array de precisión doble**. +4. Consultas avanzadas: `FOR UPDATE SKIP LOCKED` (workers), `unnest` (bulk inserts), `generate_series` (mentions), CTEs con ventanas (alerta anomalías), `ON CONFLICT DO NOTHING/UPDATE`. +5. El backend llama a `pg_dump`/`psql` desde `cmd/admin.go` para backup/restore (requiere el binario `postgresql-client` en la imagen). + +**Fuga de memoria / diferencia de tipos:** `News.ID` en Go models es `int64`; los handlers devuelven el ID string real. Los clientes deben tratar los IDs de noticia como **strings opacos**. + +### 2.7 Qdrant (Base Vectorial) + +- Colección única `news_vectors` (distancia coseno, dim derivada del modelo MiniLM = 384). +- Payload: `news_id`, `titulo`, `resumen`, `url`, `fuente`, `lang`, `fecha`, `category`, `pais`. +- El worker `qdrant-worker` hace upserts de puntos con UUIDs; marca `traducciones.vectorized=TRUE` y guarda `qdrant_point_id`. +- ⚠️ La búsqueda semántica vía API (`/api/search?semantic=true`) es un **stub vacío**: no consulta Qdrant. + +### 2.8 Redis + +- 512MB maxmemory, appendonly, requirepass. +- Usado por `ctranslator_worker` como **cache de traducciones** (títulos/cuerpos). +- El backend Go solo establece la conexión; **no usa los helpers de cache**. +- Para la app nativa: Redis es irrelevante (el servidor lo gestiona). + +### 2.9 Modelos ML y Pesos + +| Modelo | Tamaño | Uso | +|---|---|---| +| `nllb-ct2` (600M, int8) | ~600MB (`models/`) | Traducción CPU | +| `nllb-ct2-1.3b` | ~1.3GB | Traducción GPU/calidad | +| `nllb-200-distilled-600M` (tokenizer) | via HF | Tokenizer | +| `paraphrase-multilingual-MiniLM-L12-v2` | ~470MB | Embeddings | +| `es_core_news_lg` (spaCy) | ~500MB | NER | +| Mistral-7B (Ollama) | ~4GB | Categorización LLM (opcional) | +| `hf_cache/` | **7.9GB** | Caché HuggingFace | + +**Conclusión para el dispositivo:** los modelos completos no caben en móvil de forma realista. Ver §7. + +### 2.10 Superficie de API Completa + +Todas las rutas bajo `/api`, auth JWT Bearer (salvo las públicas). + +| Método | Ruta | Auth | Descripción | +|---|---|---|---| +| GET | `/health` | No | Health check | +| GET | `/api/wiki-images/*` | No | Estáticos (imágenes Wikipedia) | +| POST | `/api/auth/login` | No | Login | +| POST | `/api/auth/register` | No | Registro (1er usuario = admin) | +| GET | `/api/auth/check-first-user` | No | ¿Primer usuario? | +| GET | `/api/auth/me` | Sí | Usuario actual | +| GET | `/api/news` | No | Lista paginada con filtros `q, category_id, country_id, translated_only, page, per_page` | +| GET | `/api/news/:id` | No | Detalle + entidades con wiki | +| DELETE | `/api/news/:id` | Sí | Eliminar noticia | +| GET | `/api/feeds` | No | Feeds paginados + categorías/países + conteos | +| GET | `/api/feeds/export` | No | CSV | +| GET | `/api/feeds/:id` | No | Detalle feed | +| POST | `/api/feeds` | Sí | Crear feed | +| POST | `/api/feeds/import` | Sí | Importar CSV | +| PUT | `/api/feeds/:id` | Sí | Actualizar | +| DELETE | `/api/feeds/:id` | Sí | Eliminar | +| POST | `/api/feeds/:id/toggle` | Sí | Activar/desactivar | +| POST | `/api/feeds/:id/reactivate` | Sí | Reset fallos | +| GET | `/api/search` | No | Búsqueda texto (`q, lang`; `semantic=true` = stub) | +| GET | `/api/search/suggestions` | Sí | Términos del usuario | +| POST | `/api/searchlog` | Sí | Registrar búsqueda | +| GET | `/api/entities` | No | Entidades canónicas paginadas | +| GET | `/api/entities/news` | No | Noticias de una entidad | +| GET | `/api/entities/mentions` | No | Serie temporal de menciones | +| GET | `/api/alerts` | No | Alertas de anomalías | +| POST | `/api/alerts/:id/read` | Sí | Marcar leída | +| POST | `/api/alerts/read-all` | Sí | Marcar todas leídas | +| GET | `/api/stats` | No | Estadísticas globales | +| GET | `/api/categories` | No | Categorías | +| GET | `/api/countries` | No | Países | +| POST | `/api/admin/aliases` | Admin | Crear aliases | +| GET | `/api/admin/aliases/export` | Admin | CSV | +| POST | `/api/admin/aliases/import` | Admin | Importar CSV | +| POST | `/api/admin/entities/retype` | Admin | Cambiar tipo entidad | +| GET | `/api/admin/backup` | Admin | pg_dump SQL | +| GET | `/api/admin/backup/news` | Admin | ZIP noticias | +| POST | `/api/admin/restore` | Admin | Restaurar | +| GET | `/api/admin/users` | Admin | Usuarios | +| POST | `/api/admin/users/:id/promote` | Admin | Hacer admin | +| POST | `/api/admin/users/:id/demote` | Admin | Quitar admin | +| POST | `/api/admin/reset-db` | Admin | Vaciar 11 tablas | +| POST | `/api/admin/alerts/scan` | Admin | Ejecutar escáner | +| GET | `/api/admin/workers/status` | Admin | Estado Docker | +| GET | `/api/admin/workers/stats` | Admin | Throughput traducción | +| POST | `/api/admin/workers/config` | Admin | Config traducción | +| POST | `/api/admin/workers/start` | Admin | `docker compose up` | +| POST | `/api/admin/workers/stop` | Admin | `docker compose stop` | +| GET/POST | `/api/admin/workers/remote` | Admin | Listar/crear workers remotos | +| GET/DELETE | `/api/admin/workers/remote/:id` | Admin | Detalle/borrar | +| POST | `/api/admin/workers/remote/:id/toggle` | Admin | Online/offline | +| POST | `/api/admin/workers/remote/:id/regenerate-key` | Admin | Regenerar API key | +| GET | `/ws/worker` | API key | WebSocket workers remotos | + +### 2.11 Configuración (Variables de Entorno) + +Sin archivo `.env` en Go; todo via env vars inyectadas por Docker Compose. Tabla clave: + +| Variable | Default | Uso | +|---|---|---| +| `SERVER_PORT` | 8080 | Puerto API | +| `DATABASE_URL` | postgres://rss:rss@localhost:5432/rss | DSN API | +| `REDIS_URL` | redis://localhost:6379 | DSN cache | +| `SECRET_KEY` | — | Firma JWT | +| `JWT_EXPIRATION` | 24h | **Leído pero ignorado** | +| `TRANSLATION_URL` | http://libretranslate:7790 | Servicio traducción (sin uso real) | +| `OLLAMA_URL` | http://ollama:11434 | Embeddings/categorización | +| `SPACY_URL` | http://spacy:8000 | NER | +| `DOCKER_COMPOSE_DIR` | /datos/rss2 | Directorio para `docker compose` de admin | +| `WIKI_IMAGES_PATH` | /app/data/wiki_images | Imágenes wiki | +| `ALLOWED_ORIGINS` | * | CORS | + +Los 6 workers usan `DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASS` + variables propias (`SCRAPER_*`, `WIKI_*`, `TOPICS_*`, `RELATED_*`, `QDRANT_*`, `ALERTS_*`). + +### 2.12 Limitaciones Relevantes para la Migración + +1. **`SemanticSearch` no implementado** en la API Go (stub vacío). +2. **`JWT_EXPIRATION` ignorado** — sin refresh tokens; crítico en móvil. +3. **Admin depende de Docker** (`start/stop/status/backup/restore` hacen `exec.Command`). +4. **Sin framework de migraciones DB** (todo en el init del contenedor PostgreSQL). +5. **Esquema fuertemente PostgreSQL-específico** (tsvector, GIN, arrays, `FOR UPDATE SKIP LOCKED`). +6. **Sin tests** en frontend y sin suite CI visible para la mayoría de servicios. +7. **Sin rate-limiting real** (stub no conectado). +8. **Mensajes y UI 100% en español** hardcodeados (falta i18n). +9. **Favoritos sin backend** aunque existe la tabla `favoritos`. + +--- + +## 3. Objetivos y Requisitos de la Nueva Aplicación + +### 3.1 Requisitos Funcionales + +1. **RF-1 Lectura de noticias:** listar, filtrar (categoría, país, solo traducidas), ver detalle, entidades con Wikipedia. +2. **RF-2 Búsqueda:** texto (y semántica si se implementa), sugerencias. +3. **RF-3 Favoritos:** guardar/eliminar noticias (sincronizados con servidor). +4. **RF-4 Gestión de feeds:** CRUD, activar/desactivar, reactivar, importar/exportar CSV. +5. **RF-5 Análisis:** entidades populares, evolución temporal de menciones, alertas de anomalías. +6. **RF-6 Estadísticas globales.** +7. **RF-7 Autenticación:** login/registro, sesión persistente, logout. +8. **RF-8 Administración** (solo admin): usuarios, aliases, workers, backup/restore, reset. +9. **RF-9 Notificaciones push** (móvil) y del sistema (desktop) para nuevas alertas. +10. **RF-10 Modo offline:** lectura de noticias cacheadas sin conexión. +11. **RF-11 (Avanzado) Modo worker:** el desktop puede actuar como **worker de traducción remota** (WebSocket GPU/CPU) conectado a un servidor central. +12. **RF-12 (Avanzado) Núcleo local:** el desktop puede ejecutar el **backend completo embebido** (servidor personal local) con la API en `localhost`. + +### 3.2 Requisitos No Funcionales + +| # | Requisito | +|---|---| +| RNF-1 | Instalación nativa en Windows 10/11 x64, Linux x64 (deb/AppImage/Flatpak), Android 8+, iOS 15+. | +| RNF-2 | Tamaño de descarga: ≤80MB (sin modelos embebidos). | +| RNF-3 | Arranque < 3s; UI fluida (60fps) en dispositivos medios. | +| RNF-4 | Cifrado de credenciales (Keychain/Keyring/Keystore). | +| RNF-5 | Actualizaciones automáticas (auto-update). | +| RNF-6 | Soporte offline-first con reconciliación al reconectar. | +| RNF-7 | Cumplir políticas de tiendas (Google Play, App Store) — sin descargas de modelos gigantes en primer arranque móvil. | +| RNF-8 | Seguridad: JWT con refresh, almacenamiento seguro, `https` obligatorio en producción. | +| RNF-9 | i18n (al menos ES + EN) — aprovechar la migración para introducir `react-i18next`. | + +--- + +## 4. Arquitectura Objetivo (Alternativas) + +### 4.1 Opción A — Cliente–Servidor con Offline-First (RECOMENDADA) + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SERVIDOR RSS2 (central o personal) │ +│ Docker Compose / binario nativo → ingesta, IA, DB, Qdrant, Redis │ +│ nginx :8888 → API REST /api + WebSocket /ws/worker │ +└───────────────┬─────────────────────────────┬──────────────────────────┘ + │ HTTPS (REST + JWT refresh) │ WebSocket (trabajos) + ▼ ▼ +┌───────────────────────────────┐ ┌────────────────────────────────────┐ +│ Apps nativas (clientes) │ │ Desktop con GPU (modo worker) │ +│ Windows / Linux / Android/iOS│ │ NLLB-200 local via WebSocket │ +│ │ │ (reutiliza remote_translator) │ +│ SQLite local (caché offline) │ └────────────────────────────────────┘ +└───────────────────────────────┘ +``` + +**Ventajas:** reutilización total del backend actual (sin reescribir nada de Go/Python); las apps son "clientes ricos"; el modo worker desktop aprovecha la infraestructura WebSocket existente. +**Desventajas:** requiere conexión para ingesta en tiempo real; el modo offline es de lectura/caché. + +### 4.2 Opción B — Núcleo Embebido Total (server local en el dispositivo) + +Cada desktop ejecuta **todo el stack embebido**: backend Go + PostgreSQL embebido + Qdrant + modelos ML. Móviles se conectan al desktop o usan el servidor central. + +**Ventajas:** privacidad total, sin servidor externo. +**Desventajas:** enorme complejidad de empaquetado (Postgres/Qdrant/modelos por plataforma), requisitos de RAM/almacenamiento prohibitivos en móvil, y duplicación de todo el coste de operación. **Descartado como objetivo principal**, solo viable como **modo avanzado desktop** en Fase 5. + +### 4.3 Opción C — Híbrida (recomendada como hoja de ruta de 3 etapas) + +1. **Etapa 1 (MVP):** Opción A pura — apps nativas como clientes del servidor central. +2. **Etapa 2 (Offline):** Caché SQLite local + sincronización bidireccional + push. +3. **Etapa 3 (Pro):** Modo "worker" desktop (GPU) + modo "núcleo local" desktop (backend embebido) para usuarios avanzados y despliegues domésticos. + +**Recomendación:** **Opción C**. Es la que equilibra esfuerzo, valor y viabilidad técnica. El resto del plan se basa en ella. + +--- + +## 5. Selección Tecnológica por Plataforma + +### 5.1 Estrategia de Reuso del Frontend + +El 95% de la UI actual es React + Tailwind. Se recomienda **una sola base de código React** empaquetada con: + +| Plataforma | Tecnología | Razonamiento | +|---|---|---| +| **Windows + Linux** | **Tauri 2** (Rust shell + WebView2/WebKitGTK) | Binarios pequeños (~8-15MB), bajo consumo, API nativa para archivos/notificaciones/servicios, ideal para empaquetar el backend Go como sidecar. | +| **Android + iOS** | **Capacitor 6** (wrapper nativo de la misma SPA React) | Reutiliza la SPA tal cual; acceso a Push, SecureStorage, Filesystem, SplashScreen, autoupdate (capgo). | + +**Alternativa evaluada y rechazada:** +- **Electron:** gran ecosistema y autoupdate maduro, pero binarios de 80-120MB y alto consumo RAM; innecesario con WebView del SO moderno. (Puede elegirse si el equipo prefiere JS puro para el shell — coste en tamaño/RAM.) +- **Flutter / React Native:** supondría **reescribir** las ~4.300 líneas TSX. Solo tiene sentido si se quiere UI 100% nativa y se descarta el reuso. **No recomendado** dado el frontend existente. +- **.NET MAUI / Kotlin Multiplatform:** mismo argumento, coste de reescritura alto. + +**Arquitectura de paquetes (monorepo):** + +``` +rss2/ +├── app/ # NUEVO: aplicación multiplataforma +│ ├── packages/ +│ │ ├── core-ui/ # Componentes React reutilizables (portados) +│ │ ├── api-client/ # Cliente HTTP + tipos (portados de api.ts) +│ │ ├── storage/ # Abstracción de almacenamiento seguro +│ │ ├── sync/ # Motor de sincronización offline +│ │ └── platform/ # Bridge nativo (Tauri/Capacitor) +│ ├── src/ # SPA React compartida +│ ├── desktop/ # Shell Tauri (Rust) +│ │ └── src-tauri/ +│ ├── mobile/ # Configuración Capacitor (Android/iOS) +│ └── worker/ # Módulo "worker de traducción" embebido +│ └── (port de remote_translator_worker.py a Rust/embedded) +``` + +### 5.2 Desktop (Windows/Linux) — Tauri 2 + +- **Rust shell** con `tauri-plugin-shell`, `tauri-plugin-store`, `tauri-plugin-notification`, `tauri-plugin-autostart`, `tauri-plugin-updater` (v2), `tauri-plugin-sql` (SQLite vía rusqlite), `tauri-plugin-fs`. +- **Comunicación JS↔Rust:** comandos `invoke` (ej. `save_dialog`, `read_file`, `notification_permission`). +- **Sidecar backend Go:** compilado con `go build -ldflags "-s -w"` y registrado como **sidecar binario** de Tauri (`externalBin`). Arrancado/bajo control de Rust (`tauri-plugin-shell`), con auto-restart y registro de logs. +- **Ventana:** aprovecha WebView2 (Windows 10/11 preinstalado) y WebKitGTK (Linux). Tamaño mínimo 1024×700, redimensionable. +- **Bandeja del sistema** (tray) para background-ingesta (opcional en Fase 5). +- **Notificaciones nativas** del sistema. + +### 5.3 Android/iOS — Capacitor 6 + +- **Mismo `dist/` de Vite** sirve para web y para Capacitor (`npx cap add android/ios`). +- **Plugins requeridos:** + - `@capacitor/preferences` (o `@capacitor/secure-storage-plugin`) → sustituye `localStorage` para token. + - `@capacitor/push-notifications` → notificaciones push (FCM/APNs). + - `@capacitor/filesystem` → exportación de CSV/ZIP/SQL a la carpeta de descargas / Share. + - `@capacitor/share` → compartir noticias. + - `@capacitor/network` → detección de conectividad para el modo offline. + - `@capacitor/status-bar`, `@capacitor/splash-screen`. +- **Requiere `HashRouter`** en móvil (sin `window.history` en file://). +- **Actualizaciones:** [Capgo](https://capgo.app) o re-publicación en tiendas para updates OTA; revisión obligatoria para iOS. + +### 5.4 Servidor (cerebro) + +Dos opciones de despliegue del backend que la app debe soportar: +1. **Servidor central (SaaS/self-hosted):** el `docker-compose.yml` actual sin cambios; la app se conecta a una URL HTTPS. +2. **Núcleo embebido desktop (Fase 5):** el desktop ejecuta un **binario único** que integra la API Go + los workers esenciales (ingesta, wiki, topics, langdetect) sobre **PostgreSQL embebido portable** (ver §8), sin Docker. + +### 5.5 Base de Datos Local — SQLite SOLO como caché de cliente (nunca como almacén principal) + +**Decisión arquitectónica clave (escala: millones de noticias):** el sistema de registro (system of record) es **PostgreSQL + Qdrant en el servidor**, igual que hoy. **SQLite se usa ÚNICAMENTE como caché acotada y estado de usuario en el dispositivo** — nunca debe contener el dataset completo ni servir como almacén principal. + +Razones técnicas (derivadas del código actual): + +| Limitación de SQLite | Impacto en RSS2 a escala de millones | +|---|---| +| **Escritura serializada (single-writer)** | El `rss-ingestor-go` corre hasta 100 workers concurrentes y los workers Python usan `FOR UPDATE SKIP LOCKED`. SQLite encolaría todas las escrituras → cuello de botella de ingesta. | +| **Sin `FOR UPDATE SKIP LOCKED` ni `unnest`** | Los workers de traducción/topics dependen de estos patrones; reimplementarlos sobre SQLite degrada el rendimiento y añade bugs. | +| **FTS5 vs tsvector/GIN** | El esquema actual usa `to_tsvector('spanish', ...)` con triggers e índices GIN. FTS5 funciona, pero con millones de filas y escrituras concurrentes el mantenimiento del índice es más débil que Postgres. | +| **Embeddings y vectores** | `traduccion_embeddings` guarda arrays `DOUBLE PRECISION[]` (384 dims ≈ 3KB+/fila). 1 noticia → N traducciones → M embeddings: millones de noticias ⇒ decenas de millones de filas. La búsqueda semántica necesita **Qdrant**, no similitud in-memory de SQLite. | +| **Backups/restore** | El backend ya usa `pg_dump`/`psql`; con SQLite habría que reimplementar y perderías la madurez de esas herramientas. | + +**Dónde SÍ es válido SQLite (roles de cliente):** +- Caché de lectura con **evicción forzada** (ver §6.1: presupuesto máx. ~50k noticias y retención de 7–30 días, LRU). +- Estado de usuario: favoritos, leídos, `outbox` de escrituras pendientes, metadatos de feeds. +- Esquema local (ver §6.2) **no replica el esquema PostgreSQL**: es un esquema de aplicación mínimo para lectura rápida. + +**Alternativa evaluada:** libSQL/Turso (replicación distribuida) — solo si más adelante se quiere multi-dispositivo con el cliente como fuente. **No recomendado ahora**; el servidor PostgreSQL sigue siendo la fuente de verdad. + +### 5.6 Resumen de Decisión + +| Componente | Tecnología | Reuso del código actual | +|---|---|---| +| UI | React 18 + Tailwind (misma SPA) | 95% reutilizado | +| Desktop shell | Tauri 2 (Rust) | — | +| Móvil shell | Capacitor 6 | — | +| Cliente HTTP | Axios (portado a `api-client`) | 100% | +| Almacenamiento seguro | Keyring/Keychain/Keystore | nuevo | +| DB local | SQLite | nuevo | +| Motor de traducción (worker) | CTranslate2 (embedded, port a Rust opcional) | reutiliza lógica de `remote_translator_worker.py` | +| Servidor | Docker Compose actual / binario nativo | 100% | + +--- + +## 6. Estrategia de Datos y Sincronización + +### 6.1 Modelo Offline-First + +**Principios:** +1. **Lectura:** todas las listas de noticias/entidades/feeds se cachean en SQLite local **con presupuesto y evicción forzada**. SQLite es caché, no almacén: la app NUNCA guarda el dataset completo. +2. **Presupuesto de caché (regla dura):** máx. **50.000 noticias** en `news_cache` y retención por TTL (5 min listas, 24h detalle, 30 días fondo). Al superar el tope se aplica **LRU** (borrar por `fetched_at` más antiguo) y se recorta por antigüedad (`fecha < now - 30d`). El servidor conserva el histórico completo; la app solo su ventana de trabajo. +3. **Escritura (favoritos/leídos):** se aplican localmente al instante y se encolan en una tabla `outbox` para sincronizar. El `outbox` se vacía tras confirmación del servidor y no tiene límite práctico (son pocas filas por usuario). +4. **Sincronización:** un motor de sync (background worker) reconcilia `outbox` contra el servidor cuando hay red; conflictos resueltos por "última escritura gana" con timestamp. +5. **Rehidratación:** al reconectar, se invalida la caché y se refresca desde el servidor. + +### 6.2 Esquema SQLite Local (caché + estado, con evicción) + +```sql +-- Caché de lectura (SIEMPRE acotada: máx. ~50k filas, LRU + TTL) +CREATE TABLE news_cache ( + id TEXT PRIMARY KEY, -- MD5 de la URL (string opaco) + payload TEXT NOT NULL, -- JSON completo de la noticia + fetched_at INTEGER NOT NULL, -- epoch ms (usado por el LRU) + ttl INTEGER NOT NULL DEFAULT 86400000 +); +CREATE TABLE list_cache ( + key TEXT PRIMARY KEY, -- "news:page=1:cat=2" etc. + payload TEXT NOT NULL, + fetched_at INTEGER NOT NULL, + ttl INTEGER NOT NULL DEFAULT 300000 +); +CREATE INDEX idx_news_fetched ON news_cache(fetched_at); +-- Trabajo de evicción (job diario): +-- DELETE FROM news_cache WHERE fetched_at < now - 30d; +-- DELETE FROM news_cache WHERE id IN ( +-- SELECT id FROM news_cache ORDER BY fetched_at ASC +-- LIMIT (SELECT MAX(0, COUNT(*) - 50000) FROM news_cache)); +-- DELETE FROM list_cache WHERE fetched_at < now - ttl; + +-- Estado de usuario (sync bidireccional) +CREATE TABLE favorites ( + noticia_id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + dirty INTEGER NOT NULL DEFAULT 1 +); +CREATE TABLE read_states ( + noticia_id TEXT PRIMARY KEY, + read_at INTEGER NOT NULL, + dirty INTEGER NOT NULL DEFAULT 1 +); + +-- Cola de escrituras pendientes de sincronizar (pocas filas; se vacía al sync) +CREATE TABLE outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + op TEXT NOT NULL, -- 'add_favorite' | 'remove_favorite' | 'read' + payload TEXT NOT NULL, + created_at INTEGER NOT NULL, + synced INTEGER NOT NULL DEFAULT 0 +); + +-- Metadatos de feeds (para gestión offline) +CREATE TABLE feeds_meta ( + id INTEGER PRIMARY KEY, + nombre TEXT, url TEXT, activo INTEGER, + last_error TEXT, updated_at INTEGER +); + +-- Config local +CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT); +``` + +> **Nota de escala:** este esquema NO es una réplica del esquema PostgreSQL (que alberga los millones de noticias, traducciones, embeddings y relaciones). Es una capa de lectura/escritura mínima cuyo tamaño total se mantiene **< 100MB** gracias a la evicción. + +### 6.3 Sincronización Bidireccional + +**Flujo del motor de sync:** +1. Detectar conectividad (`@capacitor/network` / evento de red en Tauri). +2. Enviar `outbox` pendiente en lotes (con el JWT). +3. Aplicar confirmaciones (borrar filas sincronizadas). +4. Pull incremental: `GET /api/news?since=` (requiere añadir filtro `since` al backend, ver §9) → upsert en `news_cache`. +5. Refrescar `favorites` del servidor (una vez `POST /api/favorites` esté implementado, ver §6.5). + +### 6.4 Imágenes y Multimedia + +- Las imágenes de noticias son **URLs remotas**; se cachean en disco (`~/.cache/rss2` en desktop, app dir en móvil) con límite (ej. 200MB LRU). +- Las imágenes wiki se sirven desde `/api/wiki-images/`; la app debe descargarlas y cachearlas igual. +- **Lazy-loading** ya presente en las tarjetas; optimizar con `srcset`/formatos modernos. +- En móvil: preferir miniatura de 400px para ahorrar datos (sugerir añadir parámetro de tamaño al backend). + +### 6.5 Favoritos: de localStorage a sincronizados + +El frontend actual guarda favoritos en `localStorage` (objetos completos). Para offline-first real: +1. **Añadir endpoints al backend:** `GET/POST/DELETE /api/favorites` sobre la tabla `favoritos` existente (no utilizada por la UI actual). +2. La app usa la tabla local `favorites` + `outbox`. +3. Migrar los favoritos existentes de `localStorage` al SQLite local en el primer arranque de la app. + +### 6.6 Backup y Exportación Local + +- Desktop: exportar favoritos/caché a archivo (botón nativo save dialog). +- Sustituir `URL.createObjectURL`+`` por API nativa: + - Tauri: `invoke('save_dialog')` + `fs.write_file`. + - Capacitor: `Filesystem.writeFile` a `DIRECTORY.DOCUMENTS` + `Share.share` con el URI. +- Backup servidor (`/admin/backup`) sigue disponible solo en modo admin. + +--- + +## 7. Estrategia de IA/ML en el Dispositivo + +### 7.1 Realidad de los Modelos + +| Modelo | Tamaño | Desktop (x64) | Android/iOS | +|---|---|---|---| +| NLLB-200 600M (CT2 int8) | ~600MB | ✅ viable (2-4 GB RAM) | ❌ excesivo para la mayoría | +| NLLB-200 1.3B (float16) | ~2.6GB | ✅ con GPU 8GB+ | ❌ | +| MiniLM embeddings | ~470MB | ✅ | ⚠️ borde | +| spaCy es_core_news_lg | ~500MB | ✅ | ❌ | +| Mistral-7B | ~4GB | ⚠️ solo con GPU | ❌ | + +### 7.2 Modelo por Capas (tiered) + +**Capa 0 (obligatoria, sin IA local):** la app **solo consume la API**. Toda la IA corre en el servidor. Es el modo por defecto para móvil. + +**Capa 1 (desktop con GPU — modo worker):** el desktop instala (bajo demanda, descarga de HuggingFace con opción de "descargar ahora/diferir") el modelo NLLB CTranslate2. Se conecta al servidor por WebSocket (`/ws/worker`) como **worker de traducción remota**. Implementación: **portar `remote_translator_worker.py`** a un servicio interno de la app. + +Opciones de implementación del worker desktop: +- **A) Embed Python:** empaquetar el intérprete Python (PyInstaller) con `ctranslate2`+`transformers` como **proceso sidecar** de la app Tauri. Rápido de portar (reutiliza el .py tal cual) pero binarios grandes (300-500MB) y fragilidad de CTranslate2 en empaquetado. +- **B) Port a Rust `ctranslate2-rs`:** binario único pequeño, sin Python. Más trabajo pero mucho más robusto y limpio. **Recomendado a medio plazo.** +- **C) Go + cgo a CTranslate2 C API:** intermedio. + +**Recomendación:** empezar con **(A)** (time-to-market) y migrar a **(B)** como mejora de Fase 5. + +### 7.3 IA On-Device Móvil (opcional, futuro) + +Si se desea búsqueda semántica o traducción offline en móvil: +- **Traducción:** modelos ONNX/ML Kit (NLLB no es práctico; alternativas: `google` ML Kit translate offline ~80 idiomas, pesos menores). +- **Embeddings:** ONNX Runtime Mobile con MiniLM cuantizado (int8, ~40MB). +- **NER:** string matcher local (como el worker `topics` de Go) en lugar de spaCy. + +Estos se descargan **bajo demanda** con consentimiento del usuario (política de tiendas). + +### 7.4 Categorización LLM y Búsqueda Semántica + +- **Categorización LLM** (Ollama/Mistral): permanece en servidor; la app solo muestra resultados. +- **Búsqueda semántica:** el stub de la API deberá implementarse en el servidor consultando Qdrant (Fase 1 de servidor, independiente de la app). La app simplemente usará el endpoint cuando exista. + +--- + +## 8. Núcleo Embebido (Embedded Backend) + +### 8.1 Contexto + +Para la **Fase 5** (modo avanzado desktop y despliegues domésticos), se propone ejecutar el backend sin Docker: un **binario único** que integre API + workers sobre un almacén local. + +**Decisión de escala:** dado que el sistema acumulará millones de noticias, el almacén del núcleo embebido debe ser **PostgreSQL embebido**, NO SQLite. PostgreSQL embebido mantiene paridad total con el servidor (mismo esquema, mismos workers, `FOR UPDATE SKIP LOCKED`, tsvector/GIN, arrays, `pg_dump`). SQLite queda **exclusivamente como caché de cliente** (§6) y solo se ofrece como "modo limitado" para despliegues personales pequeños con techo documentado (§8.5). + +### 8.2 Estrategia de Almacén del Núcleo Embebido + +**Opción A (RECOMENDADA): PostgreSQL embebido.** + +| Aspecto | Detalle | +|---|---| +| Librería | `github.com/fergusstrange/embedded-postgres` (descarga/arranca un binario portable de Postgres) o `zonky`/`otj-pg-embedded`. | +| Compatibilidad | 100% con el esquema `init-db/*.sql` actual; mismo `DATABASE_URL`; los 6 workers Go y los handlers no cambian. | +| Backup/restore | Se reutilizan `pg_dump`/`psql` ya implementados en `internal/handlers/admin.go`. | +| Concurrencia | Soporta los 100 workers del ingestor y `FOR UPDATE SKIP LOCKED` de traducción. | +| Coste de empaquetado | +~50–80MB de binarios Postgres por SO (no es pesado para desktop). | +| Escala | Capaz de albergar millones de noticias sin cambio de diseño. | + +El binario de Postgres se registra como **sidecar** adicional de Tauri (junto al núcleo Go) y Rust lo arranca/supervisa antes de lanzar la API. Datos en el directorio de la app (`%APPDATA%/rss2/pgdata`). Puerto loopback (5432 → uno efímero) y credenciales aleatorias generadas en el primer arranque. + +**Opción B: "Modo limitado" SQLite (solo personal, con techo).** +- Válido únicamente para el perfil "usuario doméstico con feeds personales". +- **Techo duro:** ≤ 200.000 noticias, ≤ 5 feeds activos, sin búsqueda semántica (solo FTS5 de texto), sin clustering/related. +- Debe existir **ruta de migración** al superar el techo: exportar a `pg_dump`/archivo SQL e importar en un servidor PostgreSQL o en el modo A. Documentar en la UI ("Tu catálogo local ha superado el modo limitado"). +- Riesgo asumido: reescritura de consultas Postgres-específicas (`unnest`, `FOR UPDATE SKIP LOCKED`, `generate_series`) para SQLite. + +**Recomendación:** implementar **solo la Opción A** en la Fase 5. Mantener SQLite únicamente como caché de cliente. Si el equipo quiere abaratar el modo personal, la Opción B puede añadirse después como mejora, nunca como base. + +### 8.3 Componentes del Binario Único + +``` +rss2-core (un solo binario Go) +├── API REST (Gin, puerto 127.0.0.1:8080) — rutas /api completas para la app +├── Motor PostgreSQL embebido (sidecar portable, arrancado por Rust) o Postgres externo +├── Módulo de ingesta RSS (port del rss-ingestor-go) — goroutine con ticker +├── Módulo wiki (port de wiki_worker) — goroutine +├── Módulo topics (port de topics) — goroutine +├── Módulo langdetect (Go) — goroutine +├── Módulo NER ligero (string matcher) — goroutine +└── Qdrant embebido (sidecar, opcional) o búsqueda vectorial desactivada +``` + +**Configuración:** archivo `rss2.json` en el directorio de configuración del SO (`%APPDATA%/rss2`, `~/.config/rss2`, `~/Library/Application Support/rss2`). Generado en el primer arranque con secretos aleatorios. + +### 8.4 Gestión de Procesos en la App + +- **Arranque:** Tauri sidecar arranca el binario con `--serve` y espera el healthcheck (`GET /health`). +- **Supervisión:** Rust monitoriza el proceso; auto-restart tras crash (máx. 3 reintentos, backoff exponencial). +- **Shutdown ordenado:** señal SIGTERM → shutdown graceful del servidor Gin. +- **Logs:** `stderr` redirigido a `rss2.log` rotativo. +- **Seguridad:** bind solo a `127.0.0.1`; token de sesión local; CORS restringido a `tauri://localhost` y `https://app.rss2.local`. + +### 8.5 Descarga del Núcleo (Opcional en Desktop) + +El paquete del núcleo (binario Go ≈30–50MB + binarios PostgreSQL portable ≈50–80MB) puede **descargarse bajo demanda** desde el servidor de releases (GitHub Releases) con checksum SHA-256 verificado, e instalarse en el directorio de la app. Si el usuario no descarga el núcleo, la app funciona en modo cliente puro (Capa 0). + +--- + +## 9. Rediseño de la API para Clientes Nativos + +Cambios mínimos necesarios en el backend Go para soportar clientes nativos de forma robusta. + +### 9.1 Autenticación con Refresh Tokens + +El JWT actual expira en 24h fijas y la app móvil no puede pedir re-login cada día. + +1. Añadir tabla `refresh_tokens (id, user_id, token_hash, expires_at, revoked_at, device_info, created_at)`. +2. Nuevos endpoints: + - `POST /api/auth/refresh` (con refresh token → nuevo par). + - `POST /api/auth/logout` (revoca refresh). +3. Usar `JWT_EXPIRATION` config (arreglar el hardcode de `internal/auth/jwt.go`). +4. La app guarda el refresh token en SecureStorage/Keychain y lo renueva de forma transparente. + +### 9.2 Endpoints Nuevos Requeridos + +| Endpoint | Propósito | +|---|---| +| `GET/POST/DELETE /api/favorites` | Sincronización de favoritos (tabla `favoritos` ya existe) | +| `GET /api/news?since=` | Pull incremental para sync offline | +| `GET /api/news/feed` (opcional) | Feed agregado optimizado para móvil (paginado con cursor) | +| `GET /api/push/config` + `POST /api/push/register` | Registrar token FCM/APNs por usuario | +| `POST /api/alerts/:id/read` (ya existe) | — | +| `GET /api/version` | Informa versión de API para compatibilidad de la app | + +### 9.3 Notificaciones Push + +- **Servidor:** nuevo endpoint para registrar token de push; worker Go que consulta `alertas` nuevas y dispara notificaciones. +- **Android:** FCM (`firebase-messaging`); **iOS:** APNs. +- **Desktop:** notificaciones del sistema vía plugin Tauri (sin infraestructura push; eventos en tiempo real por WebSocket/polling ligero). +- **Canales:** "Nuevas alertas", "Nueva traducción disponible" (opcional), "Estado de workers" (opcional, solo admin). + +### 9.4 WebSocket para Tiempo Real + +- Actualmente solo `/ws/worker` (workers). Añadir un **canal `/ws/client`** opcional con auth JWT para: notificación de nuevas noticias, actualización de alertas en vivo, estado de traducción. La app puede degradar a polling cada 60s si el WebSocket no está disponible. + +### 9.5 Versionado de API + +- Montar la API bajo `/api/v1` (manteniendo `/api` como alias por compatibilidad web). +- La app envía `Accept-Version: 1` y `User-Agent: rss2-desktop/1.4.2` para trazabilidad y rate-limiting diferenciado. + +### 9.6 CORS y Seguridad + +- La app nativa no está sujeta a CORS (sin origin de navegador), pero Tauri WebView sí lo está: incluir `tauri://localhost`, `http://localhost:*` y `https://*.rss2.local` en `ALLOWED_ORIGINS`. +- **HTTPS obligatorio** para conexión a servidores remotos (la app rechaza `http://` salvo `localhost`). +- **Certificado pinning** opcional en móvil para servidores propios. + +### 9.7 Rediseño de Funciones Admin Dependientes de Docker + +Los endpoints `/admin/workers/start|stop|status` ejecutan `docker compose`. Para la app: +- **Modo cliente:** mostrar solo estado reportado por el servidor; ocultar controles de contenedores en móvil. +- **Modo núcleo embebido:** los controles mapean a APIs de gestión del proceso interno (no Docker). + +--- + +## 10. Migración del Frontend Paso a Paso + +### 10.1 Fase 0 — Refactor de base (sin cambios visuales) + +1. **Extraer `api-client`:** mover `services/api.ts` a un paquete independiente con inyección de `baseURL`, transporte y almacenamiento. +2. **Crear `storage.ts` (abstracción):** interfaz `SecureStore { get, set, remove }` con implementaciones: `localStorage` (web/dev), `Keyring`/`tauri-plugin-store` (desktop), `Preferences`+`SecureStorage` (móvil). +3. **Sustituir `localStorage`** (token/user/favorites) por la abstracción. +4. **Unificar fetch:** envolver las páginas que usan `api` crudo y `useEffect` para que usen `apiService` + React Query (Populares, Alertas, Analisis, Admin*). +5. **Arreglar deudas menores:** `btn-danger`, filtros muertos de Search, `Account` persistente, limpiar imports muertos (Favorites, clsx), implementar `cn()`. +6. **Añadir i18n:** `react-i18next` con diccionarios ES/EN; marcar todos los strings. +7. **Introducir router portable:** pasar de `BrowserRouter` a un wrapper que use `BrowserRouter` en web y `HashRouter` en móvil (detectable por variable de compilación). + +### 10.2 Fase 1 — Abstracción de APIs de navegador + +| Función | Abstracción (`platform/`) | Desktop (Tauri) | Móvil (Capacitor) | +|---|---|---|---| +| Descarga de archivos | `saveFile(name, bytes)` | `invoke('save_dialog')` + fs | `Filesystem.writeFile` + `Share` | +| Diálogos | `confirmDialog(msg)` | `invoke('confirm')` (Rust nativo) | modales React (no window.confirm) | +| Notificación | `notify(title, body)` | plugin notification | plugin push/local | +| Conectividad | `onNetworkChange(cb)` | eventos Rust | plugin network | +| Fecha/hora | date-fns (ya portable) | — | — | +| Apertura de enlaces | `openExternal(url)` | plugin opener | `Browser.open` (in-app browser) | + +### 10.3 Fase 2 — Adaptaciones móviles + +1. **Nav responsive:** sustituir `hidden sm:flex` por un componente de navegación con hamburguesa / bottom-tab en móvil. +2. **WikiTooltip táctil:** añadir soporte de tap (primer tap muestra, segundo navega) — reemplazar hover-only. +3. **Tablas** (Feeds, Alertas, Admin): en móvil, convertir a listas de tarjetas en vez de `overflow-x-auto`. +4. **LineChart:** `min-w-[560px]` → rescalar a viewBox responsivo (eliminar scroll horizontal). +5. **Safe areas:** padding con `env(safe-area-inset-*)`. +6. **Pull-to-refresh** en listas (react-query + plugin de refresco nativo). +7. **Infinite scroll / paginación** para listas de noticias en lugar de paginación numérica. + +### 10.4 Fase 3 — Offline y Sincronización + +1. Integrar `storage`/SQLite local (`tauri-plugin-sql`, `@capacitor-community/sqlite`). +2. Implementar el motor de sync del §6.3. +3. Convertir las consultas a React Query para servir primero de caché local (`placeholderData`/`initialData` del SQLite) y refrescar del servidor. +4. Estado de conexión: banner "Modo offline" + cola de acciones pendientes. + +### 10.5 Fase 4 — Shells Nativos + +**Tauri (desktop):** +- `npm create tauri-app` + config en `app/desktop/src-tauri`. +- Plugins: shell (sidecar), sql, store, notification, autostart, updater. +- `tauri.conf.json`: `externalBin` → `binaries/rss2-core-*`, ventana 1024×700, capacidades/perms restringidas. +- Build matrix (ver §12). + +**Capacitor (móvil):** +- `npx cap init` + `npx cap add android && npx cap add ios`. +- Plugins listados en §5.3. +- `capacitor.config.ts`: `webDir: dist`, `androidScheme: https`. +- Firma de APK/AAB con keystore; provisioning para iOS. + +### 10.6 Gestión de Sesión en la App + +- Flujo: login → guardar access token (memoria + SecureStorage) y refresh token (SecureStorage). +- Interceptor: 401 → intentar refresh → reenviar petición → si falla, logout. +- Almacenar `user` (JSON) en SecureStorage y sincronizar estado de sesión por eventos internos (en vez de `storage` event del navegador). + +--- + +## 11. Empaquetado y Distribución por Plataforma + +### 11.1 Windows + +- **Formato:** NSIS installer (`.exe`) vía Tauri bundler; MSI opcional (WiX). +- **Firma:** certificado Authenticode (EV recomendado) con `signtool` en CI (Secret: `WINDOWS_CERT_BASE64`, `WINDOWS_CERT_PASSWORD`). +- **SmartScreen:** firmar y/o añadir reputación; la primera versión no firmada mostrará aviso. +- **Actualizaciones:** `tauri-plugin-updater` con endpoint de manifest (GitHub Releases). +- **WebView2:** Runtime instalado por defecto en Win10/11; incluir fallback de instalación silenciosa. +- **Instalación por usuario:** sin privilegios admin (per-user), o instalador system-wide opcional. + +### 11.2 Linux + +- **Formatos:** `.deb` (Debian/Ubuntu), `.rpm` (Fedora), **AppImage** (universal, recomendado para testing), **Flatpak** (distribución a largo plazo), `.tar.gz` portable. +- **WebKitGTK:** dependencia `webkit2gtk-4.1` + `librsvg`; documentar en README del paquete. +- **Firma:** GPG para repos; Flatpak vía Flathub (si se desea). +- **Actualizaciones:** AppImageUpdate (fork de AppImage) o el updater de Tauri con endpoint propio. + +### 11.3 Android + +- **Formato:** **AAB** (App Bundle) para Google Play; APK firmado para distribución directa. +- **Firma:** keystore `app-release.keystore` (guardar en CI como secret; **nunca en git**). +- **minSdk:** 24 (Android 7.0+) recomendado por Capacitor 6 (minSdk 22+). +- **Play Console:** política de "descarga de modelos on-demand" documentada; contenido de noticias = "News" (requiere moderación de contenido si es social). +- **Permisos:** `INTERNET`, `POST_NOTIFICATIONS` (Android 13+), `VIBRATE`. +- **Distribución alternativa:** F-Droid / directa (APK en el sitio). + +### 11.4 iOS + +- **Formato:** `.ipa` vía App Store Connect (requiere Mac + Xcode + cuenta Apple Developer $99/año). +- **Firma:** certificado Apple Distribution + provisioning profile (secrets en CI macOS). +- **App Store:** política de contenido de noticias; pantalla de "primeros pasos"; descarga de modelos bajo demanda. +- **Notarización** de macOS (si en el futuro se soporta macOS) — fuera de alcance inicial (solo Win/Linux desktop). +- **Actualización:** OTA solo vía App Store (Capgo no funciona con el App Store para updates; requeriría MDM/supervisión). Descartar OTA en iOS. + +### 11.5 Política de Actualizaciones + +| Plataforma | Mecanismo | +|---|---| +| Windows | tauri-plugin-updater (manifest JSON en GitHub Releases) | +| Linux | tauri-plugin-updater (AppImage) / repos apt/Flatpak | +| Android | Play Store + directa (APK) + opcional Capgo | +| iOS | App Store (única vía) | + +### 11.6 Tamaños Estimados del Instalador + +| Plataforma | Sin núcleo | Con núcleo embebido | Con worker Python (GPU) | +|---|---|---|---| +| Windows | ~25MB | ~60MB | ~400MB | +| Linux | ~25MB | ~60MB | ~400MB | +| Android | ~15MB | — | — | +| iOS | ~15MB | — | — | + +--- + +## 12. Infraestructura de Build y CI/CD + +### 12.1 Pipeline GitHub Actions (matrix) + +```yaml +name: build-app +on: [push, pull_request, workflow_dispatch] + +jobs: + web: + runs-on: ubuntu-latest + steps: + - checkout + - setup-node, npm ci + - npm run build # tsc + vite build (dist/) + - npm run test # vitest (nuevos tests) + desktop-windows: + needs: web + runs-on: windows-latest + steps: + - npm ci + - cargo build --release (Tauri) + - tauri build --bundles nsis + - firma (signtool) + upload artifact + desktop-linux: + needs: web + runs-on: ubuntu-latest + steps: + - apt: libwebkit2gtk-4.1-dev, libgtk-3-dev, libayatana-appindicator3-dev + - tauri build --bundles deb,appimage + - upload artifact + android: + needs: web + runs-on: ubuntu-latest + steps: + - java 17, Android SDK + - npx cap sync android + - gradlew assembleRelease (AAB + APK) + - upload artifact + ios: + needs: web + runs-on: macos-14 + steps: + - npx cap sync ios + - xcodebuild archive + export + - (opcional) upload to TestFlight via App Store Connect API + backend-core: + runs-on: matrix (windows, ubuntu) + steps: + - go build -o rss2-core-{os}-{arch} ./cmd/rss2-core # nuevo binario + - upload artifact + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: [desktop-windows, desktop-linux, android, backend-core] + steps: + - download artifacts + - create GitHub Release con checksums (sha256sum) + - publish manifest.json del updater +``` + +### 12.2 Versionado + +- **Semver** estricto (`1.4.2`). El `package.json` (app) y el manifiesto del updater comparten versión. +- Tag `v*` dispara la release. `main` dispara builds de desarrollo (artifacts sin firmar). + +### 12.3 Secretos en CI + +| Secret | Uso | +|---|---| +| `WINDOWS_CERT_BASE64` / `WINDOWS_CERT_PASSWORD` | Firma Authenticode | +| `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_PASSWORD` / `ANDROID_KEY_ALIAS` | Firma AAB/APK | +| `APPLE_DIST_CERT_BASE64` / `APPLE_PROVISIONING_BASE64` | Firma iOS | +| `GH_TOKEN` | Publicar releases + manifest | +| `FCM_SERVER_KEY` / `APNS_KEY` | Push (si se testea desde CI) | + +--- + +## 13. Plan de Implementación por Fases + +### Fase 0 — Preparación y Refactor (2–3 semanas) +**Objetivo:** base de código portable sin cambios de comportamiento. +- [ ] Extraer `api-client` y `storage` como paquetes. +- [ ] Sustituir `localStorage` por `SecureStore`. +- [ ] Unificar fetch (apiService + React Query) en todas las páginas. +- [ ] Arreglar deudas técnicas §2.3 (btn-danger, Search, Account, Favorites, i18n, router portable). +- [ ] Añadir tests base (vitest + Testing Library) para `api-client`, `storage` y componentes clave. +- **Entregable:** `npm run build` + `npm test` verde; la web sigue funcionando igual en Docker. + +### Fase 1 — Backend: Refresh Tokens + API para móvil (2 semanas) +- [ ] Tabla `refresh_tokens` + endpoints `/auth/refresh`, `/auth/logout`. +- [ ] Arreglar `JWT_EXPIRATION` en `internal/auth/jwt.go`. +- [ ] Endpoints `/api/favorites` (GET/POST/DELETE). +- [ ] `GET /api/news?since=` (pull incremental). +- [ ] `/api/version`. +- [ ] Implementar `SemanticSearch` real contra Qdrant (opcional pero recomendado). +- [ ] Tests Go para los nuevos endpoints (patrón existente en `internal/handlers/auth_test.go`). +- **Entregable:** API versionada y consumible por clientes nativos. + +### Fase 2 — Shell Desktop Tauri (Windows/Linux) — MVP (3–4 semanas) +- [ ] Proyecto Tauri + integración del `dist/` de Vite. +- [ ] Plugins: store, notification, opener, updater. +- [ ] Abstracciones `platform/` (saveFile, confirmDialog, notify). +- [ ] Login/registro + sesión persistente con refresh. +- [ ] Pantallas principales: Home, News, Search, Favorites, Feeds (read-only), Stats. +- [ ] Instaladores NSIS (Win) y deb/AppImage (Linux). +- **Entregable:** instalador de escritorio funcional conectado a un servidor central. + +### Fase 3 — Shell Móvil Capacitor (Android/iOS) (4–5 semanas) +- [ ] Proyecto Capacitor + sync de la SPA. +- [ ] Plugins: preferences, secure-storage, filesystem, share, network, push, splash, status-bar. +- [ ] HashRouter + adaptaciones móviles §10.3 (nav, tooltips, tablas, chart). +- [ ] Push notifications (FCM/APNs) + endpoint `/push/register`. +- [ ] Builds Android (AAB) e iOS (ipa) en CI. +- **Entregable:** apps en Android e iOS instalables (TestFlight/Play internal testing). + +### Fase 4 — Offline-First y Sincronización (3–4 semanas) +- [ ] SQLite local (desktop + móvil) con esquema §6.2. +- [ ] Motor de sync (outbox + pull incremental) §6.3. +- [ ] Favoritos sincronizados (backend + app). +- [ ] Caché de imágenes con límite LRU. +- [ ] Modo offline de lectura + banner de estado. +- [ ] Pull-to-refresh e infinite scroll. +- **Entregable:** app funcional offline con reconciliación al reconectar. + +### Fase 5 — Núcleo Embebido y Modo Worker (5–8 semanas) +- [ ] Binario `rss2-core` (Go) + **PostgreSQL embebido portable** (Opción A) con el esquema `init-db/*.sql` intacto. **Descartado SQLite como almacén principal** (escala de millones); SQLite queda solo como caché de cliente. +- [ ] Sidecar Postgres (arranque/supervisión/shutdown por Rust) + sidecar del núcleo. +- [ ] Port de ingesta RSS, wiki, topics, langdetect al núcleo. +- [ ] Supervisión de procesos: auto-restart, logs rotativos, healthcheck. +- [ ] Configuración local `rss2.json` + wizard de "Configurar servidor local". +- [ ] Descarga bajo demanda del núcleo y de Postgres portable (checksum verificado). +- [ ] Modo worker: port de `remote_translator_worker.py` (opción A: PyInstaller sidecar; mejora B: Rust). +- [ ] Gestión de workers remotos en la UI (crear API key, ver estado) — ya existe en AdminWorkers. +- **Entregable:** desktop autónomo (sin Docker, a escala de millones vía Postgres embebido) + aporte de GPU a la red de traducción. + +### Fase 6 — Distribución y Tiendas (2–3 semanas) +- [ ] Firmas de producción (Windows, Android, iOS). +- [ ] Publicación Play Store + App Store + GitHub Releases. +- [ ] Manifiesto de auto-update y roll-out gradual. +- [ ] Dashboard de métricas de uso (opcional: telemetría anonimizada). +- [ ] Documentación de usuario final por plataforma. +- **Entregable:** releases públicas en las 4 plataformas. + +### Cronograma Total Estimado +| Fase | Duración | Acumulado | +|---|---|---| +| 0 | 2–3 sem | 3 sem | +| 1 | 2 sem | 5 sem | +| 2 | 3–4 sem | 9 sem | +| 3 | 4–5 sem | 14 sem | +| 4 | 3–4 sem | 18 sem | +| 5 | 5–8 sem | 26 sem | +| 6 | 2–3 sem | 29 sem | + +**~7 meses** con un equipo de 2-3 personas (1 full-stack React/TS, 1 Go/backend, 0.5 Rust/mobile). El MVP (Fases 0-2) se puede alcanzar en **~2 meses**. + +--- + +## 14. Riesgos y Mitigaciones + +| # | Riesgo | Probabilidad | Impacto | Mitigación | +|---|---|---|---|---| +| 1 | **Empaquetado de CTranslate2/Python en desktop** (dependencias .so, torch cu118) | Alta | Alto | Empezar con PyInstaller sidecar; roadmap hacia port Rust `ctranslate2-rs`; documentar el `patchelf --clear-execstack` existente. | +| 2 | **Políticas de tiendas (iOS/Android)** por descarga de modelos grandes | Media | Alto | Modelos **on-demand** con consentimiento; nunca en el APK; Capa 0 (sin IA local) como default móvil. | +| 3 | **Coste de mantenimiento de 3 shells** (web, Tauri, Capacitor) | Alta | Medio | Mantener **una sola SPA**; los shells son delgados; abstracciones `platform/` estrictas; tests de contrato. | +| 4 | **JWT/sesión en móvil** (24h fija, sin refresh) | Alta | Medio | Fase 1 implementa refresh tokens; SecureStorage para los tokens. | +| 5 | **SQLite ≠ PostgreSQL** en el núcleo embebido | Baja (decisión: usar PostgreSQL embebido) | Bajo | El núcleo usa **PostgreSQL portable** con el esquema actual intacto; SQLite queda solo como caché de cliente acotada. Si se añade "modo limitado" SQLite, llevar techo duro documentado + ruta de exportación a Postgres. | +| 6 | **Favoritos sin backend** (localStorage hoy) | Alta | Baja | Fase 1 añade endpoints; migración de datos local en primer arranque. | +| 7 | **Notificaciones push requieren backend** nuevo (FCM/APNs) | Media | Medio | Worker Go de push; empezar por alertas; degradar a notificaciones locales en desktop. | +| 8 | **Rendimiento del WebView** en dispositivos Android baratos | Media | Medio | Caché SQLite + infinite scroll; reducir tamaño de bundle (code-splitting); limitar tarjetas cargadas. | +| 9 | **Seguridad** (almacenar refresh token, pinning) | Media | Alta | Keychain/Keystore/Keyring; HTTPS obligatorio; opción de pinning; auditoría de secrets. | +| 10 | **Traducción de toda la UI (i18n)** retrasa fases | Media | Baja | Empezar i18n en Fase 0 (ES como default), EN incremental. | +| 11 | **Escasez de expertise Rust/mobile** | Media | Media | Usar Capacitor (JS puro, sin Swift/Kotlin deep); Rust mínimo en Tauri; plantilla de Tauri como base. | +| 12 | **`SemanticSearch` stub** — expectativa de búsqueda semántica en la app | Media | Medio | Implementarla en servidor (Fase 1 opcional) o desactivar el toggle en la app. | + +--- + +## 15. Estrategia de Testing y QA + +### 15.1 Niveles de Testing + +| Nivel | Stack | Alcance | +|---|---|---| +| Unit (TS) | vitest + Testing Library | `api-client`, `storage`, `sync`, componentes, hooks | +| Unit (Go) | `go test` | nuevos endpoints (favorites, refresh, push), helpers | +| Integration API | vitest + msw / Go httptest | contratos `api-client` ↔ backend | +| E2E web | Playwright | flujos principales (login, home, search, favorites) | +| E2E desktop | Playwright con WebView (Tauri driver) | humo por plataforma | +| E2E móvil | Detox / Maestro (emulador) | login, offline, push (móvil) | +| Visual | Percy/regression | 4 plataformas, light/dark | +| Rendimiento | Lighthouse (web) / bundle-size budget | CI gate: bundle < 250KB gzip core | + +### 15.2 Cobertura Mínima Obligatoria + +1. `api-client`: cada endpoint con mock (éxito, 401→refresh→retry, 401→logout, errores de red). +2. `storage`: round-trip, corrupción, rotación de versión. +3. `sync`: outbox vacío, envío, fallo→reintento, conflictos. +4. `favorites`: flujo local→sync→servidor. +5. Componentes clave: `Layout`, `ProtectedRoute`, tarjetas de noticia, `WikiTooltip` táctil. + +### 15.3 Testing Manual por Plataforma (checklist) + +- [ ] **Windows:** instalación NSIS, auto-update, notificaciones, tray, WebView2 ausente→instalar, SmartScreen. +- [ ] **Linux:** AppImage/deb en Ubuntu+Debian+Fedora, webkit2gtk instalado, escalado HiDPI. +- [ ] **Android:** AAB instalado, push FCM, permiso notificaciones, orientación, modo offline/avión, background. +- [ ] **iOS:** TestFlight, push APNs, permiso notificaciones, Safe Area, Dark Mode. + +--- + +## 16. Referencias y Documentación + +- [README.md](./README.md) — Guía de despliegue completo del sistema actual. +- [DEPLOY.md](./DEPLOY.md) — Instrucciones de producción. +- [AGENTS.md](./AGENTS.md) — Guía de desarrollo del repositorio. +- [remote-worker.md](./remote-worker.md) — Protocolo de workers remotos (base para el módulo worker desktop). +- [SECURITY_GUIDE.md](./SECURITY_GUIDE.md) — Guía de seguridad (a revisar para almacenamiento de tokens). +- [QUICKSTART_LLM.md](./QUICKSTART_LLM.md) — Configuración LLM. +- **Ficheros clave del código analizado:** + - `docker-compose.yml` — 20 servicios, topología de redes, recursos. + - `backend/cmd/server/main.go` — arranque, rutas, migraciones idempotentes. + - `backend/internal/handlers/*.go` — toda la superficie de API. + - `backend/internal/auth/jwt.go` — JWT (24h hardcode). + - `backend/internal/services/ml.go` — clientes ML (SemanticSearch stub). + - `frontend/src/services/api.ts` — contrato API + interceptor 401. + - `frontend/src/App.tsx`, `main.tsx` — boot y routing. + - `frontend/src/pages/*.tsx` — las 18 páginas. + - `workers/remote_translator_worker.py` — prototipo del modo worker desktop. + - `workers/ctranslator_worker.py`, `embeddings_worker.py`, `ner_worker.py` — pipelines IA. + - `rss-ingestor-go/main.go` — ingesta con politeness y ETag. + - `init-db/*.sql` — esquema completo (28 tablas). + - `nginx.conf` — gateway. + +--- + +### Apéndice A — Tabla de Correspondencia Frontend↔App + +| Página actual | Estado | Acción en la app | +|---|---|---| +| Home | ✅ | Port directo + infinite scroll | +| News | ✅ | Port + favorito offline | +| Search | ⚠️ | Corregir filtros muertos; semántica condicional | +| Feeds | ✅ | CRUD (solo desktop/admin) | +| Favorites | ⚠️ | Migrar de localStorage a sync | +| Account | ⚠️ | Implementar persistencia real | +| Login | ✅ | Añadir refresh tokens | +| Populares | ✅ | Port | +| Analisis | ✅ | Chart responsivo | +| Alertas | ✅ | + push notifications | +| WelcomeWizard | ✅ | + wizard "configurar servidor local" (Fase 5) | +| AdminAliases/Users/Settings/Workers | ⚠️ | Ocultar/adaptar acciones dependientes de Docker en móvil | + +### Apéndice B — Glosario + +- **Sidecar:** binario adicional empaquetado junto a la app y lanzado por el shell (ej. el backend Go embebido). +- **Offline-first:** diseño donde la app funciona sin red usando datos locales y sincroniza después. +- **Outbox:** cola local de escrituras pendientes de confirmar por el servidor. +- **Capa 0/1/2 de IA:** modelo de capacidades de IA según el dispositivo (cliente puro / worker GPU / núcleo local). +- **FTS5:** módulo de búsqueda full-text de SQLite (equivalente al tsvector de Postgres). diff --git a/plan-mejora.md b/plan-mejora.md new file mode 100644 index 0000000..84f8f4e --- /dev/null +++ b/plan-mejora.md @@ -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 \ No newline at end of file diff --git a/translate-worker b/translate-worker new file mode 100755 index 0000000..1b8671a --- /dev/null +++ b/translate-worker @@ -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 "$@" \ No newline at end of file diff --git a/translate_worker_termux.py b/translate_worker_termux.py new file mode 100755 index 0000000..3b60b78 --- /dev/null +++ b/translate_worker_termux.py @@ -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() \ No newline at end of file diff --git a/workers/ctranslator_worker.py b/workers/ctranslator_worker.py index 5bb94a3..c391953 100644 --- a/workers/ctranslator_worker.py +++ b/workers/ctranslator_worker.py @@ -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 """, diff --git a/workers/translation_scheduler.py b/workers/translation_scheduler.py index 64a480f..3fd5e2c 100644 --- a/workers/translation_scheduler.py +++ b/workers/translation_scheduler.py @@ -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