cambios
This commit is contained in:
parent
3991237685
commit
a01050bf85
46 changed files with 3636 additions and 652 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
35
backend/internal/handlers/feed_test.go
Normal file
35
backend/internal/handlers/feed_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
38
backend/internal/handlers/news_test.go
Normal file
38
backend/internal/handlers/news_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
35
backend/internal/handlers/search_test.go
Normal file
35
backend/internal/handlers/search_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
14
backend/internal/handlers/validation.go
Normal file
14
backend/internal/handlers/validation.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue