327 lines
8.7 KiB
Go
327 lines
8.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/rss2/backend/internal/auth"
|
|
"github.com/rss2/backend/internal/db"
|
|
"github.com/rss2/backend/internal/models"
|
|
"github.com/rss2/backend/internal/services"
|
|
)
|
|
|
|
// LogSearch records a logged-in user's search term so frequent searches gain priority.
|
|
func LogSearch(c *gin.Context) {
|
|
user := c.MustGet("user").(*auth.Claims)
|
|
var req struct {
|
|
Query string `json:"q"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid request"})
|
|
return
|
|
}
|
|
term := strings.ToLower(strings.TrimSpace(req.Query))
|
|
if term == "" {
|
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "q requerido"})
|
|
return
|
|
}
|
|
if len(term) > 100 {
|
|
term = term[:100]
|
|
}
|
|
|
|
_, err := db.GetPool().Exec(c.Request.Context(), `
|
|
INSERT INTO user_search_tags (user_id, term, count, last_used)
|
|
VALUES ($1, $2, 1, NOW())
|
|
ON CONFLICT (user_id, term)
|
|
DO UPDATE SET count = user_search_tags.count + 1, last_used = NOW()
|
|
`, user.UserID, term)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to save search", Message: err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
// SearchSuggestions returns the user's most used search terms (dynamic tags).
|
|
func SearchSuggestions(c *gin.Context) {
|
|
user := c.MustGet("user").(*auth.Claims)
|
|
q := strings.TrimSpace(c.Query("q"))
|
|
|
|
query := `
|
|
SELECT term FROM user_search_tags
|
|
WHERE user_id = $1`
|
|
args := []interface{}{user.UserID}
|
|
if q != "" {
|
|
query += ` AND term ILIKE $2`
|
|
args = append(args, "%"+q+"%")
|
|
}
|
|
query += ` ORDER BY count DESC, last_used DESC LIMIT 10`
|
|
|
|
rows, err := db.GetPool().Query(c.Request.Context(), query, args...)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get suggestions", Message: err.Error()})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
terms := []string{}
|
|
for rows.Next() {
|
|
var t string
|
|
if err := rows.Scan(&t); err != nil {
|
|
continue
|
|
}
|
|
terms = append(terms, t)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"terms": terms})
|
|
}
|
|
|
|
func SearchNews(c *gin.Context) {
|
|
query := c.Query("q")
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "30"))
|
|
lang := c.DefaultQuery("lang", "")
|
|
categoriaID := c.Query("categoria_id")
|
|
paisID := c.Query("pais_id")
|
|
useSemantic := c.Query("semantic") == "true"
|
|
|
|
if query == "" && categoriaID == "" && paisID == "" && lang == "" {
|
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "At least one filter is required (q, categoria_id, pais_id, or lang)"})
|
|
return
|
|
}
|
|
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 || perPage > 100 {
|
|
perPage = 30
|
|
}
|
|
|
|
// Default to Spanish if no lang specified
|
|
if lang == "" {
|
|
lang = "es"
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
if useSemantic {
|
|
results, err := services.SemanticSearch(ctx, query, lang, page, perPage)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Semantic search failed", Message: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, results)
|
|
return
|
|
}
|
|
|
|
offset := (page - 1) * perPage
|
|
|
|
// Build dynamic query
|
|
args := []interface{}{lang}
|
|
argNum := 2
|
|
whereClause := "WHERE 1=1"
|
|
|
|
if query != "" {
|
|
whereClause += " AND (n.titulo ILIKE $" + strconv.Itoa(argNum) + " OR n.resumen ILIKE $" + strconv.Itoa(argNum) + ")"
|
|
args = append(args, "%"+query+"%")
|
|
argNum++
|
|
}
|
|
|
|
if categoriaID != "" {
|
|
if catID, err := strconv.ParseInt(categoriaID, 10, 64); err == nil {
|
|
whereClause += " AND n.categoria_id = $" + strconv.Itoa(argNum)
|
|
args = append(args, catID)
|
|
argNum++
|
|
}
|
|
}
|
|
|
|
if paisID != "" {
|
|
if pID, err := strconv.ParseInt(paisID, 10, 64); err == nil {
|
|
whereClause += " AND n.pais_id = $" + strconv.Itoa(argNum)
|
|
args = append(args, pID)
|
|
argNum++
|
|
}
|
|
}
|
|
|
|
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,
|
|
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 = $1
|
|
` + whereClause + `
|
|
ORDER BY n.fecha DESC
|
|
LIMIT $` + strconv.Itoa(argNum) + ` OFFSET $` + strconv.Itoa(argNum+1)
|
|
|
|
args = append(args, perPage, offset)
|
|
|
|
rows, err := db.GetPool().Query(ctx, sqlQuery, args...)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Search failed", Message: err.Error()})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var newsList []NewsResponse
|
|
for rows.Next() {
|
|
var n NewsResponse
|
|
var imagenURL, fuenteNombre *string
|
|
var categoriaIDp, paisIDp *int64
|
|
|
|
err := rows.Scan(
|
|
&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
|
|
&categoriaIDp, &paisIDp, &fuenteNombre,
|
|
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
|
|
)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if imagenURL != nil {
|
|
n.ImagenURL = imagenURL
|
|
}
|
|
if fuenteNombre != nil {
|
|
n.FuenteNombre = *fuenteNombre
|
|
}
|
|
n.CategoriaID = categoriaIDp
|
|
n.PaisID = paisIDp
|
|
newsList = append(newsList, n)
|
|
}
|
|
|
|
// Get total count
|
|
countArgs := args[:len(args)-2]
|
|
|
|
var total int
|
|
err = db.GetPool().QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM noticias n
|
|
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $1
|
|
`+whereClause, countArgs...).Scan(&total)
|
|
if err != nil {
|
|
total = len(newsList)
|
|
}
|
|
|
|
totalPages := (total + perPage - 1) / perPage
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"news": newsList,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": perPage,
|
|
"total_pages": totalPages,
|
|
})
|
|
}
|
|
|
|
func GetStats(c *gin.Context) {
|
|
var stats models.Stats
|
|
|
|
err := db.GetPool().QueryRow(c.Request.Context(), `
|
|
SELECT
|
|
(SELECT COUNT(*) FROM noticias) as total_news,
|
|
(SELECT COUNT(*) FROM feeds WHERE activo = true) as total_feeds,
|
|
(SELECT COUNT(*) FROM users) as total_users,
|
|
(SELECT COUNT(*) FROM noticias WHERE fecha::date = CURRENT_DATE) as news_today,
|
|
(SELECT COUNT(*) FROM noticias WHERE fecha >= DATE_TRUNC('week', CURRENT_DATE)) as news_this_week,
|
|
(SELECT COUNT(*) FROM noticias WHERE fecha >= DATE_TRUNC('month', CURRENT_DATE)) as news_this_month,
|
|
(SELECT COUNT(DISTINCT noticia_id) FROM traducciones WHERE status = 'done') as total_translated
|
|
`).Scan(
|
|
&stats.TotalNews, &stats.TotalFeeds, &stats.TotalUsers,
|
|
&stats.NewsToday, &stats.NewsThisWeek, &stats.NewsThisMonth,
|
|
&stats.TotalTranslated,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get stats", Message: err.Error()})
|
|
return
|
|
}
|
|
|
|
rows, err := db.GetPool().Query(c.Request.Context(), `
|
|
SELECT c.id, c.nombre, COUNT(n.id) as count
|
|
FROM categorias c
|
|
LEFT JOIN noticias n ON n.categoria_id = c.id
|
|
GROUP BY c.id, c.nombre
|
|
ORDER BY count DESC
|
|
LIMIT 10
|
|
`)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var cs models.CategoryStat
|
|
rows.Scan(&cs.CategoryID, &cs.CategoryName, &cs.Count)
|
|
stats.TopCategories = append(stats.TopCategories, cs)
|
|
}
|
|
}
|
|
|
|
rows, err = db.GetPool().Query(c.Request.Context(), `
|
|
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
|
|
GROUP BY p.id, p.nombre, p.flag_emoji
|
|
ORDER BY count DESC
|
|
LIMIT 10
|
|
`)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var cs models.CountryStat
|
|
rows.Scan(&cs.CountryID, &cs.CountryName, &cs.FlagEmoji, &cs.Count)
|
|
stats.TopCountries = append(stats.TopCountries, cs)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, stats)
|
|
}
|
|
|
|
func GetCategories(c *gin.Context) {
|
|
rows, err := db.GetPool().Query(c.Request.Context(), `
|
|
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()})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type Category struct {
|
|
ID int64 `json:"id"`
|
|
Nombre string `json:"nombre"`
|
|
}
|
|
|
|
var categories []Category
|
|
for rows.Next() {
|
|
var cat Category
|
|
rows.Scan(&cat.ID, &cat.Nombre)
|
|
categories = append(categories, cat)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, categories)
|
|
}
|
|
|
|
func GetCountries(c *gin.Context) {
|
|
rows, err := db.GetPool().Query(c.Request.Context(), `
|
|
SELECT p.id, p.nombre, c.nombre as continente
|
|
FROM paises p
|
|
LEFT JOIN continentes c ON c.id = p.continente_id
|
|
ORDER BY p.nombre`)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get countries", Message: err.Error()})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type Country struct {
|
|
ID int64 `json:"id"`
|
|
Nombre string `json:"nombre"`
|
|
Continente string `json:"continente"`
|
|
}
|
|
|
|
var countries []Country
|
|
for rows.Next() {
|
|
var country Country
|
|
rows.Scan(&country.ID, &country.Nombre, &country.Continente)
|
|
countries = append(countries, country)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, countries)
|
|
}
|