coconews/backend/internal/handlers/feed.go

540 lines
14 KiB
Go

package handlers
import (
"context"
"encoding/csv"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"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"`
}
func GetFeeds(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
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
where := "1=1"
args := []interface{}{}
argNum := 1
if activo != "" {
where += fmt.Sprintf(" AND activo = $%d", argNum)
args = append(args, activo == "true")
argNum++
}
if categoriaID != "" {
where += fmt.Sprintf(" AND categoria_id = $%d", argNum)
args = append(args, categoriaID)
argNum++
}
if paisID != "" {
where += fmt.Sprintf(" AND pais_id = $%d", argNum)
args = append(args, paisID)
argNum++
}
var total int
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM feeds WHERE %s", where)
err := db.GetPool().QueryRow(c.Request.Context(), countQuery, args...).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count feeds", Message: err.Error()})
return
}
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,
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
LEFT JOIN categorias c ON c.id = f.categoria_id
LEFT JOIN paises p ON p.id = f.pais_id
WHERE %s
ORDER BY p.nombre NULLS LAST, f.activo DESC, f.fallos ASC, c.nombre NULLS LAST, f.nombre
LIMIT $%d OFFSET $%d
`, where, argNum, argNum+1)
args = append(args, perPage, offset)
rows, err := db.GetPool().Query(c.Request.Context(), sqlQuery, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to fetch feeds", Message: err.Error()})
return
}
defer rows.Close()
type FeedWithStats struct {
FeedResponse
Categoria *string `json:"categoria"`
Pais *string `json:"pais"`
NoticiasCount int64 `json:"noticias_count"`
}
var feeds []FeedWithStats
for rows.Next() {
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.Categoria, &f.Pais, &f.NoticiasCount,
)
if err != nil {
continue
}
feeds = append(feeds, f)
}
totalPages := (total + perPage - 1) / perPage
c.JSON(http.StatusOK, gin.H{
"feeds": feeds,
"total": total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
})
}
func GetFeedByID(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid feed ID"})
return
}
var f FeedResponse
err = db.GetPool().QueryRow(c.Request.Context(), `
SELECT id, nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos
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,
)
if err != nil {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
return
}
c.JSON(http.StatusOK, f)
}
type CreateFeedRequest struct {
Nombre string `json:"nombre" binding:"required"`
URL string `json:"url" binding:"required,url"`
Descripcion *string `json:"descripcion"`
CategoriaID *int64 `json:"categoria_id"`
PaisID *int64 `json:"pais_id"`
Idioma *string `json:"idioma"`
}
func CreateFeed(c *gin.Context) {
var req CreateFeedRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid request", Message: err.Error()})
return
}
var feedID int64
err := db.GetPool().QueryRow(c.Request.Context(), `
INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
req.Nombre, req.Descripcion, req.URL, req.CategoriaID, req.PaisID, req.Idioma,
).Scan(&feedID)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to create feed", Message: err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": feedID, "message": "Feed created successfully"})
}
type UpdateFeedRequest struct {
Nombre string `json:"nombre" binding:"required"`
URL string `json:"url" binding:"required,url"`
Descripcion *string `json:"descripcion"`
CategoriaID *int64 `json:"categoria_id"`
PaisID *int64 `json:"pais_id"`
Idioma *string `json:"idioma"`
Activo *bool `json:"activo"`
}
func UpdateFeed(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid feed ID"})
return
}
var req UpdateFeedRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid request", Message: err.Error()})
return
}
activeVal := true
if req.Activo != nil {
activeVal = *req.Activo
}
result, err := db.GetPool().Exec(c.Request.Context(), `
UPDATE feeds
SET nombre = $1, descripcion = $2, url = $3,
categoria_id = $4, pais_id = $5, idioma = $6, activo = $7
WHERE id = $8`,
req.Nombre, req.Descripcion, req.URL,
req.CategoriaID, req.PaisID, req.Idioma, activeVal, id,
)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to update feed", Message: err.Error()})
return
}
if result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
return
}
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed updated successfully"})
}
func DeleteFeed(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid feed ID"})
return
}
result, err := db.GetPool().Exec(c.Request.Context(), "DELETE FROM feeds WHERE id = $1", id)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to delete feed", Message: err.Error()})
return
}
if result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
return
}
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed deleted successfully"})
}
func ToggleFeedActive(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid feed ID"})
return
}
result, err := db.GetPool().Exec(c.Request.Context(), `
UPDATE feeds SET activo = NOT activo WHERE id = $1`, id)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to toggle feed", Message: err.Error()})
return
}
if result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
return
}
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed toggled successfully"})
}
func ReactivateFeed(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid feed ID"})
return
}
result, err := db.GetPool().Exec(c.Request.Context(), `
UPDATE feeds SET activo = TRUE, fallos = 0 WHERE id = $1`, id)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to reactivate feed", Message: err.Error()})
return
}
if result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
return
}
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed reactivated successfully"})
}
func ExportFeeds(c *gin.Context) {
activo := c.Query("activo")
categoriaID := c.Query("categoria_id")
paisID := c.Query("pais_id")
where := "1=1"
args := []interface{}{}
argNum := 1
if activo != "" {
where += fmt.Sprintf(" AND activo = $%d", argNum)
args = append(args, activo == "true")
argNum++
}
if categoriaID != "" {
where += fmt.Sprintf(" AND categoria_id = $%d", argNum)
args = append(args, categoriaID)
argNum++
}
if paisID != "" {
where += fmt.Sprintf(" AND pais_id = $%d", argNum)
args = append(args, paisID)
argNum++
}
query := fmt.Sprintf(`
SELECT f.id, f.nombre, f.descripcion, f.url,
f.categoria_id, c.nombre AS categoria,
f.pais_id, p.nombre AS pais,
f.idioma, f.activo, f.fallos
FROM feeds f
LEFT JOIN categorias c ON c.id = f.categoria_id
LEFT JOIN paises p ON p.id = f.pais_id
WHERE %s
ORDER BY f.id
`, where)
rows, err := db.GetPool().Query(c.Request.Context(), query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to fetch feeds", Message: err.Error()})
return
}
defer rows.Close()
c.Header("Content-Type", "text/csv")
c.Header("Content-Disposition", "attachment; filename=feeds_export.csv")
writer := csv.NewWriter(c.Writer)
defer writer.Flush()
writer.Write([]string{"id", "nombre", "descripcion", "url", "categoria_id", "categoria", "pais_id", "pais", "idioma", "activo", "fallos"})
for rows.Next() {
var id int64
var nombre, url string
var descripcion, idioma *string
var categoriaID, paisID, fallos *int64
var activo bool
var categoria, pais *string
err := rows.Scan(&id, &nombre, &descripcion, &url, &categoriaID, &categoria, &paisID, &pais, &idioma, &activo, &fallos)
if err != nil {
continue
}
writer.Write([]string{
fmt.Sprintf("%d", id),
nombre,
stringOrEmpty(descripcion),
url,
int64ToString(categoriaID),
stringOrEmpty(categoria),
int64ToString(paisID),
stringOrEmpty(pais),
stringOrEmpty(idioma),
fmt.Sprintf("%t", activo),
int64ToString(fallos),
})
}
}
func ImportFeeds(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "No file provided"})
return
}
f, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to open file", Message: err.Error()})
return
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to read file", Message: err.Error()})
return
}
reader := csv.NewReader(strings.NewReader(string(content)))
_, err = reader.Read()
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid CSV format"})
return
}
imported := 0
skipped := 0
failed := 0
errors := []string{}
tx, err := db.GetPool().Begin(context.Background())
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to start transaction", Message: err.Error()})
return
}
defer tx.Rollback(context.Background())
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
failed++
continue
}
if len(record) < 4 {
skipped++
continue
}
nombre := strings.TrimSpace(record[1])
url := strings.TrimSpace(record[3])
if nombre == "" || url == "" {
skipped++
continue
}
var descripcion *string
if len(record) > 2 && strings.TrimSpace(record[2]) != "" {
descripcionStr := strings.TrimSpace(record[2])
descripcion = &descripcionStr
}
var categoriaID *int64
if len(record) > 4 && strings.TrimSpace(record[4]) != "" {
catID, err := strconv.ParseInt(strings.TrimSpace(record[4]), 10, 64)
if err == nil {
categoriaID = &catID
}
}
var paisID *int64
if len(record) > 6 && strings.TrimSpace(record[6]) != "" {
pID, err := strconv.ParseInt(strings.TrimSpace(record[6]), 10, 64)
if err == nil {
paisID = &pID
}
}
var idioma *string
if len(record) > 8 && strings.TrimSpace(record[8]) != "" {
lang := strings.TrimSpace(record[8])
if len(lang) > 2 {
lang = lang[:2]
}
idioma = &lang
}
activo := true
if len(record) > 9 && strings.TrimSpace(record[9]) != "" {
activo = strings.ToLower(strings.TrimSpace(record[9])) == "true"
}
var fallos int64
if len(record) > 10 && strings.TrimSpace(record[10]) != "" {
f, err := strconv.ParseInt(strings.TrimSpace(record[10]), 10, 64)
if err == nil {
fallos = f
}
}
var existingID int64
err = tx.QueryRow(context.Background(), "SELECT id FROM feeds WHERE url = $1", url).Scan(&existingID)
if err == nil {
_, err = tx.Exec(context.Background(), `
UPDATE feeds SET nombre=$1, descripcion=$2, categoria_id=$3, pais_id=$4, idioma=$5, activo=$6, fallos=$7
WHERE id=$8`,
nombre, descripcion, categoriaID, paisID, idioma, activo, fallos, existingID,
)
if err != nil {
failed++
errors = append(errors, fmt.Sprintf("Error updating %s: %v", url, err))
continue
}
} else {
_, err = tx.Exec(context.Background(), `
INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
nombre, descripcion, url, categoriaID, paisID, idioma, activo, fallos,
)
if err != nil {
failed++
errors = append(errors, fmt.Sprintf("Error inserting %s: %v", url, err))
continue
}
}
imported++
}
if err := tx.Commit(context.Background()); err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to commit transaction", Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"imported": imported,
"skipped": skipped,
"failed": failed,
"errors": errors,
"message": fmt.Sprintf("Import completed. Imported: %d, Skipped: %d, Failed: %d", imported, skipped, failed),
})
}
func stringOrEmpty(s *string) string {
if s == nil {
return ""
}
return *s
}
func int64ToString(i *int64) string {
if i == nil {
return ""
}
return fmt.Sprintf("%d", *i)
}