760 lines
21 KiB
Go
760 lines
21 KiB
Go
package handlers
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/rss2/backend/internal/db"
|
|
"github.com/rss2/backend/internal/models"
|
|
)
|
|
|
|
|
|
|
|
func CreateAlias(c *gin.Context) {
|
|
var req models.EntityAliasRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
tx, err := db.GetPool().Begin(ctx)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start transaction", "message": err.Error()})
|
|
return
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
// 1. Ensure the canonical tag exists in tags table
|
|
var canonicalTagId int
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO tags (valor, tipo) VALUES ($1, $2)
|
|
ON CONFLICT (valor, tipo) DO UPDATE SET valor = EXCLUDED.valor
|
|
RETURNING id`, req.CanonicalName, req.Tipo).Scan(&canonicalTagId)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to ensure canonical tag", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
for _, alias := range req.Aliases {
|
|
alias = strings.TrimSpace(alias)
|
|
if alias == "" {
|
|
continue
|
|
}
|
|
|
|
// Insert the alias mapping into entity_aliases
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO entity_aliases (canonical_name, alias, tipo)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (alias, tipo) DO UPDATE SET canonical_name = EXCLUDED.canonical_name`,
|
|
req.CanonicalName, alias, req.Tipo)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert alias", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
// 2. Check if the original alias string actually exists as a tag
|
|
var aliasTagId int
|
|
err = tx.QueryRow(ctx, "SELECT id FROM tags WHERE valor = $1 AND tipo = $2", alias, req.Tipo).Scan(&aliasTagId)
|
|
if err == nil && aliasTagId != 0 && aliasTagId != canonicalTagId {
|
|
// 3. Move all mentions in tags_noticia to the canonical tag id safely
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE tags_noticia
|
|
SET tag_id = $1
|
|
WHERE tag_id = $2 AND NOT EXISTS (
|
|
SELECT 1 FROM tags_noticia tn2
|
|
WHERE tn2.tag_id = $1 AND tn2.noticia_id = tags_noticia.noticia_id AND tn2.traduccion_id = tags_noticia.traduccion_id
|
|
)
|
|
`, canonicalTagId, aliasTagId)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reassign news mentions safely", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Delete any remaining orphaned mentions of the alias that couldn't be merged (duplicates)
|
|
_, err = tx.Exec(ctx, "DELETE FROM tags_noticia WHERE tag_id = $1", aliasTagId)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete orphaned mentions", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
// 4. Delete the original alias tag
|
|
_, err = tx.Exec(ctx, "DELETE FROM tags WHERE id = $1", aliasTagId)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete old tag", "message": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"message": "Aliases created and metrics merged successfully",
|
|
"canonical_name": req.CanonicalName,
|
|
"aliases_added": req.Aliases,
|
|
"tipo": req.Tipo,
|
|
})
|
|
}
|
|
|
|
|
|
|
|
func ExportAliases(c *gin.Context) {
|
|
rows, err := db.GetPool().Query(c.Request.Context(),
|
|
"SELECT alias, canonical_name, tipo FROM entity_aliases ORDER BY tipo, canonical_name")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get aliases", "message": err.Error()})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
c.Header("Content-Type", "text/csv")
|
|
c.Header("Content-Disposition", "attachment; filename=aliases.csv")
|
|
c.Header("Cache-Control", "no-cache")
|
|
|
|
writer := csv.NewWriter(c.Writer)
|
|
writer.Write([]string{"alias", "canonical_name", "tipo"})
|
|
|
|
for rows.Next() {
|
|
var alias, canonical, tipo string
|
|
rows.Scan(&alias, &canonical, &tipo)
|
|
writer.Write([]string{alias, canonical, tipo})
|
|
}
|
|
writer.Flush()
|
|
}
|
|
|
|
func ImportAliases(c *gin.Context) {
|
|
file, err := c.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"})
|
|
return
|
|
}
|
|
|
|
src, err := file.Open()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open file"})
|
|
return
|
|
}
|
|
defer src.Close()
|
|
|
|
reader := csv.NewReader(src)
|
|
records, err := reader.ReadAll()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse CSV", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
if len(records) < 2 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "CSV file is empty or has no data rows"})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
tx, err := db.GetPool().Begin(ctx)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start transaction"})
|
|
return
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
inserted := 0
|
|
skipped := 0
|
|
|
|
for i, record := range records[1:] {
|
|
if len(record) < 3 {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
alias := strings.TrimSpace(record[0])
|
|
canonical := strings.TrimSpace(record[1])
|
|
tipo := strings.TrimSpace(record[2])
|
|
|
|
if alias == "" || canonical == "" {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
_, err = tx.Exec(ctx,
|
|
"INSERT INTO entity_aliases (alias, canonical_name, tipo) VALUES ($1, $2, $3) ON CONFLICT (alias, tipo) DO UPDATE SET canonical_name = $2",
|
|
alias, canonical, tipo)
|
|
if err != nil {
|
|
fmt.Printf("Error inserting row %d: %v\n", i+1, err)
|
|
skipped++
|
|
continue
|
|
}
|
|
inserted++
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Import completed",
|
|
"inserted": inserted,
|
|
"skipped": skipped,
|
|
})
|
|
}
|
|
|
|
func GetAdminStats(c *gin.Context) {
|
|
var totalUsers, totalAliases int
|
|
|
|
db.GetPool().QueryRow(c.Request.Context(), "SELECT COUNT(*) FROM users").Scan(&totalUsers)
|
|
db.GetPool().QueryRow(c.Request.Context(), "SELECT COUNT(*) FROM entity_aliases").Scan(&totalAliases)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"total_users": totalUsers,
|
|
"total_aliases": totalAliases,
|
|
})
|
|
}
|
|
|
|
func GetUsers(c *gin.Context) {
|
|
rows, err := db.GetPool().Query(c.Request.Context(), `
|
|
SELECT id, email, username, is_admin, created_at, updated_at
|
|
FROM users ORDER BY created_at DESC`)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get users", "message": err.Error()})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type UserRow struct {
|
|
ID int64 `json:"id"`
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
IsAdmin bool `json:"is_admin"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
var users []UserRow
|
|
for rows.Next() {
|
|
var u UserRow
|
|
if err := rows.Scan(&u.ID, &u.Email, &u.Username, &u.IsAdmin, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
continue
|
|
}
|
|
users = append(users, u)
|
|
}
|
|
|
|
if users == nil {
|
|
users = []UserRow{}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"users": users, "total": len(users)})
|
|
}
|
|
|
|
func PromoteUser(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
|
return
|
|
}
|
|
|
|
result, err := db.GetPool().Exec(c.Request.Context(), "UPDATE users SET is_admin = true WHERE id = $1", id)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to promote user", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
if result.RowsAffected() == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "User promoted to admin"})
|
|
}
|
|
|
|
func DemoteUser(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
|
return
|
|
}
|
|
|
|
result, err := db.GetPool().Exec(c.Request.Context(), "UPDATE users SET is_admin = false WHERE id = $1", id)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to demote user", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
if result.RowsAffected() == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "User demoted from admin"})
|
|
}
|
|
|
|
func ResetDatabase(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
tables := []string{
|
|
"noticias",
|
|
"feeds",
|
|
"traducciones",
|
|
"tags_noticia",
|
|
"tags",
|
|
"entity_aliases",
|
|
"favoritos",
|
|
"videos",
|
|
"video_parrillas",
|
|
"eventos",
|
|
"search_history",
|
|
}
|
|
|
|
tx, err := db.GetPool().Begin(ctx)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start transaction"})
|
|
return
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
for _, table := range tables {
|
|
_, err = tx.Exec(ctx, "DELETE FROM "+table)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete from " + table, "message": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Database reset successfully. All data has been deleted.",
|
|
"tables_cleared": tables,
|
|
})
|
|
}
|
|
|
|
type WorkerConfig struct {
|
|
Type string `json:"type"`
|
|
Workers int `json:"workers"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
func GetWorkerStatus(c *gin.Context) {
|
|
var translatorType, translatorWorkers, translatorStatus string
|
|
|
|
err := db.GetPool().QueryRow(c.Request.Context(), "SELECT value FROM config WHERE key = 'translator_type'").Scan(&translatorType)
|
|
if err != nil {
|
|
translatorType = "cpu"
|
|
}
|
|
|
|
err = db.GetPool().QueryRow(c.Request.Context(), "SELECT value FROM config WHERE key = 'translator_workers'").Scan(&translatorWorkers)
|
|
if err != nil {
|
|
translatorWorkers = "2"
|
|
}
|
|
|
|
err = db.GetPool().QueryRow(c.Request.Context(), "SELECT value FROM config WHERE key = 'translator_status'").Scan(&translatorStatus)
|
|
if err != nil {
|
|
translatorStatus = "stopped"
|
|
}
|
|
|
|
workers, _ := strconv.Atoi(translatorWorkers)
|
|
|
|
// Verificar si los contenedores están corriendo
|
|
runningCount := 0
|
|
if translatorStatus == "running" {
|
|
cmd := exec.Command("docker", "compose", "ps", "-q", "translator")
|
|
output, _ := cmd.Output()
|
|
if len(output) > 0 {
|
|
runningCount = workers
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"type": translatorType,
|
|
"workers": workers,
|
|
"status": translatorStatus,
|
|
"running": runningCount,
|
|
})
|
|
}
|
|
|
|
func SetWorkerConfig(c *gin.Context) {
|
|
var req WorkerConfig
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
if req.Type != "cpu" && req.Type != "gpu" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Type must be 'cpu' or 'gpu'"})
|
|
return
|
|
}
|
|
|
|
if req.Workers < 1 || req.Workers > 8 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Workers must be between 1 and 8"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
_, err := db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_type'", req.Type)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update translator_type"})
|
|
return
|
|
}
|
|
|
|
_, err = db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_workers'", strconv.Itoa(req.Workers))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update translator_workers"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Worker configuration updated",
|
|
"type": req.Type,
|
|
"workers": req.Workers,
|
|
"status": req.Status,
|
|
})
|
|
}
|
|
|
|
func StartWorkers(c *gin.Context) {
|
|
var req WorkerConfig
|
|
c.ShouldBindJSON(&req)
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// Obtener configuración actual
|
|
var translatorType, translatorWorkers string
|
|
err := db.GetPool().QueryRow(ctx, "SELECT value FROM config WHERE key = 'translator_type'").Scan(&translatorType)
|
|
if err != nil || translatorType == "" {
|
|
translatorType = "cpu"
|
|
}
|
|
err = db.GetPool().QueryRow(ctx, "SELECT value FROM config WHERE key = 'translator_workers'").Scan(&translatorWorkers)
|
|
if err != nil || translatorWorkers == "" {
|
|
translatorWorkers = "2"
|
|
}
|
|
|
|
if req.Type != "" {
|
|
translatorType = req.Type
|
|
}
|
|
if req.Workers > 0 {
|
|
translatorWorkers = strconv.Itoa(req.Workers)
|
|
}
|
|
|
|
workers, _ := strconv.Atoi(translatorWorkers)
|
|
if workers < 1 {
|
|
workers = 2
|
|
}
|
|
if workers > 8 {
|
|
workers = 8
|
|
}
|
|
|
|
// Determinar qué servicio iniciar
|
|
serviceName := "translator"
|
|
if translatorType == "gpu" {
|
|
serviceName = "translator-gpu"
|
|
}
|
|
|
|
// Detener cualquier translator existente
|
|
stopCmd := exec.Command("docker", "compose", "stop", "translator", "translator-gpu")
|
|
stopCmd.Dir = "/datos/rss2"
|
|
stopCmd.Run()
|
|
|
|
// Iniciar con el número de workers
|
|
startCmd := exec.Command("docker", "compose", "up", "-d", "--scale", fmt.Sprintf("%s=%d", serviceName, workers), serviceName)
|
|
startCmd.Dir = "/datos/rss2"
|
|
output, err := startCmd.CombinedOutput()
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "Failed to start workers",
|
|
"details": string(output),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Actualizar estado en BD
|
|
db.GetPool().Exec(ctx, "UPDATE config SET value = 'running', updated_at = NOW() WHERE key = 'translator_status'")
|
|
db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_type'", translatorType)
|
|
db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_workers'", translatorWorkers)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Workers started successfully",
|
|
"type": translatorType,
|
|
"workers": workers,
|
|
"status": "running",
|
|
})
|
|
}
|
|
|
|
func StopWorkers(c *gin.Context) {
|
|
// Detener traductores
|
|
cmd := exec.Command("docker", "compose", "stop", "translator", "translator-gpu")
|
|
cmd.Dir = "/datos/rss2"
|
|
output, err := cmd.CombinedOutput()
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "Failed to stop workers",
|
|
"details": string(output),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Actualizar estado en BD
|
|
db.GetPool().Exec(c.Request.Context(), "UPDATE config SET value = 'stopped', updated_at = NOW() WHERE key = 'translator_status'")
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Workers stopped successfully",
|
|
"status": "stopped",
|
|
})
|
|
}
|
|
|
|
// PatchEntityTipo changes the tipo of all tags matching a given valor
|
|
func PatchEntityTipo(c *gin.Context) {
|
|
var req struct {
|
|
Valor string `json:"valor" binding:"required"`
|
|
NewTipo string `json:"new_tipo" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
validTipos := map[string]bool{"persona": true, "organizacion": true, "lugar": true, "tema": true}
|
|
if !validTipos[req.NewTipo] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid tipo. Must be persona, organizacion, lugar or tema"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
tx, err := db.GetPool().Begin(ctx)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start transaction", "message": err.Error()})
|
|
return
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
// Since we don't know the exact old Tipo, we find all tags with this valor that ARE NOT already the new tipo
|
|
rows, err := tx.Query(ctx, "SELECT id, tipo FROM tags WHERE valor = $1 AND tipo != $2", req.Valor, req.NewTipo)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch existing tags", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
type OldTag struct {
|
|
ID int
|
|
Tipo string
|
|
}
|
|
var tagsToMove []OldTag
|
|
for rows.Next() {
|
|
var ot OldTag
|
|
if err := rows.Scan(&ot.ID, &ot.Tipo); err == nil {
|
|
tagsToMove = append(tagsToMove, ot)
|
|
}
|
|
}
|
|
rows.Close()
|
|
|
|
if len(tagsToMove) == 0 {
|
|
c.JSON(http.StatusOK, gin.H{"message": "No entities found to update or already the requested tipo"})
|
|
return
|
|
}
|
|
|
|
// Make sure the target tag (valor, new_tipo) exists
|
|
var targetTagId int
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO tags (valor, tipo) VALUES ($1, $2)
|
|
ON CONFLICT (valor, tipo) DO UPDATE SET valor = EXCLUDED.valor
|
|
RETURNING id`, req.Valor, req.NewTipo).Scan(&targetTagId)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to ensure target tag", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
totalMoved := 0
|
|
for _, old := range tagsToMove {
|
|
if old.ID == targetTagId {
|
|
continue
|
|
}
|
|
|
|
// Move valid tags_noticia references to the target tag id safely
|
|
res, err := tx.Exec(ctx, `
|
|
UPDATE tags_noticia
|
|
SET tag_id = $1
|
|
WHERE tag_id = $2 AND NOT EXISTS (
|
|
SELECT 1 FROM tags_noticia tn2
|
|
WHERE tn2.tag_id = $1 AND tn2.noticia_id = tags_noticia.noticia_id AND tn2.traduccion_id = tags_noticia.traduccion_id
|
|
)
|
|
`, targetTagId, old.ID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reassign news mentions", "message": err.Error()})
|
|
return
|
|
}
|
|
totalMoved += int(res.RowsAffected())
|
|
|
|
// Delete any remaining orphaned mentions (duplicates)
|
|
_, err = tx.Exec(ctx, "DELETE FROM tags_noticia WHERE tag_id = $1", old.ID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete orphaned mentions", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Delete the old tag since it's now merged
|
|
_, err = tx.Exec(ctx, "DELETE FROM tags WHERE id = $1", old.ID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete old tag", "message": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Entity tipo updated and merged successfully",
|
|
"valor": req.Valor,
|
|
"new_tipo": req.NewTipo,
|
|
"tags_merged": len(tagsToMove),
|
|
"rows_affected": totalMoved,
|
|
})
|
|
}
|
|
|
|
// BackupDatabase runs pg_dump and returns the SQL as a downloadable file
|
|
func BackupDatabase(c *gin.Context) {
|
|
dbHost := os.Getenv("DB_HOST")
|
|
if dbHost == "" {
|
|
dbHost = "db"
|
|
}
|
|
dbPort := os.Getenv("DB_PORT")
|
|
if dbPort == "" {
|
|
dbPort = "5432"
|
|
}
|
|
dbName := os.Getenv("DB_NAME")
|
|
if dbName == "" {
|
|
dbName = "rss"
|
|
}
|
|
dbUser := os.Getenv("DB_USER")
|
|
if dbUser == "" {
|
|
dbUser = "rss"
|
|
}
|
|
dbPass := os.Getenv("DB_PASS")
|
|
|
|
cmd := exec.Command("pg_dump",
|
|
"-h", dbHost,
|
|
"-p", dbPort,
|
|
"-U", dbUser,
|
|
"-d", dbName,
|
|
"--no-password",
|
|
)
|
|
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPass))
|
|
|
|
var out bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "pg_dump failed",
|
|
"details": stderr.String(),
|
|
})
|
|
return
|
|
}
|
|
|
|
filename := fmt.Sprintf("backup_%s.sql", time.Now().Format("2006-01-02_15-04-05"))
|
|
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())
|
|
}
|
|
|
|
// BackupNewsZipped performs a pg_dump of news tables and returns a ZIP file
|
|
func BackupNewsZipped(c *gin.Context) {
|
|
dbHost := os.Getenv("DB_HOST")
|
|
if dbHost == "" {
|
|
dbHost = "db"
|
|
}
|
|
dbPort := os.Getenv("DB_PORT")
|
|
if dbPort == "" {
|
|
dbPort = "5432"
|
|
}
|
|
dbName := os.Getenv("DB_NAME")
|
|
if dbName == "" {
|
|
dbName = "rss"
|
|
}
|
|
dbUser := os.Getenv("DB_USER")
|
|
if dbUser == "" {
|
|
dbUser = "rss"
|
|
}
|
|
dbPass := os.Getenv("DB_PASS")
|
|
|
|
// Tables to backup
|
|
tables := []string{"noticias", "traducciones", "tags", "tags_noticia"}
|
|
|
|
args := []string{
|
|
"-h", dbHost,
|
|
"-p", dbPort,
|
|
"-U", dbUser,
|
|
"-d", dbName,
|
|
"--no-password",
|
|
}
|
|
|
|
for _, table := range tables {
|
|
args = append(args, "-t", table)
|
|
}
|
|
|
|
cmd := exec.Command("pg_dump", args...)
|
|
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPass))
|
|
|
|
var sqlOut bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cmd.Stdout = &sqlOut
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "pg_dump failed",
|
|
"details": stderr.String(),
|
|
})
|
|
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())
|
|
}
|
|
|