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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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