This commit is contained in:
Lizandro Guarnizo
2026-05-14 22:30:31 -05:00
parent 277ad6bb63
commit 61b3eca31d
16 changed files with 1341 additions and 25 deletions
@@ -0,0 +1,206 @@
package controllers
import (
"fmt"
"mime/multipart"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// GetClienteDocumentos devuelve todos los documentos de un cliente.
func GetClienteDocumentos(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("clienteID"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
docs, err := models.GetDocumentosByCliente(uint(id))
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(docs)
}
// UploadClienteDocumentos recibe uno o más archivos (campo "archivos") y un campo
// opcional "fecha_expedicion" (YYYY-MM-DD) por cada archivo o uno compartido.
func UploadClienteDocumentos(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("clienteID"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
form, err := c.MultipartForm()
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Formulario inválido: " + err.Error()})
}
files := form.File["archivos"]
if len(files) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "Ningún archivo recibido"})
}
// fecha_expedicion puede venir como un array (uno por archivo) o uno solo compartido
fechas := form.Value["fecha_expedicion"]
nombres := form.Value["nombre"]
uploadDir := fmt.Sprintf("uploads/clientes/%d", id)
if err := os.MkdirAll(uploadDir, 0750); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "No se pudo crear directorio"})
}
var creados []models.ClienteDocumento
for i, fh := range files {
// Validar tamaño máximo: 20 MB
if fh.Size > 20*1024*1024 {
return c.Status(400).JSON(fiber.Map{"error": fmt.Sprintf("Archivo '%s' supera 20 MB", fh.Filename)})
}
// Validar extensión permitida
if !extensionPermitida(fh.Filename) {
return c.Status(400).JSON(fiber.Map{"error": fmt.Sprintf("Tipo de archivo no permitido: %s", fh.Filename)})
}
safeName := sanitizeFilename(fh.Filename)
destPath := filepath.Join(uploadDir, fmt.Sprintf("%d_%s", time.Now().UnixNano(), safeName))
if err := saveUploadedFile(fh, destPath); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Error guardando archivo: " + err.Error()})
}
doc := models.ClienteDocumento{
ClienteID: uint(id),
Archivo: destPath,
OriginalName: fh.Filename,
TipoMime: fh.Header.Get("Content-Type"),
Tamanio: fh.Size,
}
// Nombre descriptivo
if i < len(nombres) && strings.TrimSpace(nombres[i]) != "" {
doc.Nombre = strings.TrimSpace(nombres[i])
} else {
doc.Nombre = fh.Filename
}
// Fecha de expedición
if i < len(fechas) && fechas[i] != "" {
if t, err := time.Parse("2006-01-02", fechas[i]); err == nil {
doc.FechaExpedicion = &t
}
} else if len(fechas) == 1 && fechas[0] != "" {
if t, err := time.Parse("2006-01-02", fechas[0]); err == nil {
doc.FechaExpedicion = &t
}
}
if err := models.CreateClienteDocumento(&doc); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Error guardando en BD: " + err.Error()})
}
creados = append(creados, doc)
}
return c.Status(201).JSON(fiber.Map{"ok": true, "creados": len(creados), "documentos": creados})
}
// DeleteClienteDocumento elimina un documento y su archivo en disco.
func DeleteClienteDocumento(c *fiber.Ctx) error {
docID, err := strconv.ParseUint(c.Params("docID"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
doc, err := models.GetClienteDocumentoByID(uint(docID))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Documento no encontrado"})
}
// Eliminar archivo físico (no fatal si no existe)
_ = os.Remove(doc.Archivo)
if err := models.DeleteClienteDocumento(uint(docID)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "message": "Documento eliminado"})
}
// DownloadClienteDocumento sirve el archivo para descarga directa.
func DownloadClienteDocumento(c *fiber.Ctx) error {
docID, err := strconv.ParseUint(c.Params("docID"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
doc, err := models.GetClienteDocumentoByID(uint(docID))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Documento no encontrado"})
}
// Asegurarse de que el path no escape del directorio uploads/
cleanPath := filepath.Clean(doc.Archivo)
if !strings.HasPrefix(cleanPath, "uploads/") {
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
}
return c.Download(cleanPath, doc.OriginalName)
}
// ─── helpers ─────────────────────────────────────────────────────────────────
var extensionesPermitidas = map[string]bool{
".pdf": true, ".doc": true, ".docx": true,
".xls": true, ".xlsx": true, ".csv": true,
".png": true, ".jpg": true, ".jpeg": true,
".gif": true, ".webp": true, ".txt": true,
".zip": true, ".rar": true,
}
func extensionPermitida(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename))
return extensionesPermitidas[ext]
}
func sanitizeFilename(name string) string {
base := filepath.Base(name)
// Eliminar caracteres peligrosos
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' {
return r
}
return '_'
}, base)
return safe
}
func saveUploadedFile(fh *multipart.FileHeader, dest string) error {
src, err := fh.Open()
if err != nil {
return err
}
defer src.Close()
out, err := os.Create(dest) //nolint:gosec
if err != nil {
return err
}
defer out.Close()
buf := make([]byte, 32*1024)
for {
n, err := src.Read(buf)
if n > 0 {
if _, werr := out.Write(buf[:n]); werr != nil {
return werr
}
}
if err != nil {
break
}
}
return nil
}
+79
View File
@@ -1,9 +1,12 @@
package controllers
import (
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
@@ -184,6 +187,82 @@ func DeleteSaasApiConfig(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"ok": true})
}
// ProbaSaasApiConfig envía una petición de prueba al endpoint configurado.
// Body JSON opcional: { "payload": "..." } — si no se envía usa el payload_template tal cual.
func ProbaSaasApiConfig(c *fiber.Ctx) error {
idParam := c.Params("id")
id64, err := strconv.ParseUint(idParam, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
cfg, err := models.GetSaasApiConfigByID(uint(id64))
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no encontrado"})
}
type Req struct {
Payload string `json:"payload"`
}
var req Req
_ = c.BodyParser(&req)
body := strings.TrimSpace(req.Payload)
if body == "" {
body = strings.TrimSpace(cfg.PayloadTemplate)
}
timeout := cfg.TimeoutSeg
if timeout <= 0 {
timeout = 10
}
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
var httpReq *http.Request
metodo := strings.ToUpper(cfg.Metodo)
if metodo == "" {
metodo = "POST"
}
if body != "" && metodo != "GET" {
httpReq, err = http.NewRequest(metodo, cfg.EndpointURL, strings.NewReader(body))
if err != nil {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"error": "URL inválida: " + err.Error()})
}
httpReq.Header.Set("Content-Type", "application/json")
} else {
httpReq, err = http.NewRequest(metodo, cfg.EndpointURL, nil)
if err != nil {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"error": "URL inválida: " + err.Error()})
}
}
if cfg.ApiKeyHeader != "" && cfg.ApiKeyValue != "" {
httpReq.Header.Set(cfg.ApiKeyHeader, cfg.ApiKeyValue)
}
httpReq.Header.Set("User-Agent", "u-site-tester/1.0")
start := time.Now()
resp, err := client.Do(httpReq)
latency := time.Since(start).Milliseconds()
if err != nil {
return c.JSON(fiber.Map{
"ok": false,
"error": err.Error(),
"latency_ms": latency,
})
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) // máx 64 KB
return c.JSON(fiber.Map{
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"http_status": resp.StatusCode,
"body": string(respBytes),
"latency_ms": latency,
})
}
// ─── Panel: logs de despacho ──────────────────────────────────────────────────
// SaasDispatchLogIndex renderiza la vista de logs de despacho.
+38
View File
@@ -1,8 +1,12 @@
package controllers
import (
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
@@ -57,6 +61,7 @@ func CreateSaasProducto(c *fiber.Ctx) error {
ServicioID *uint `json:"servicio_id"`
Activo bool `json:"activo"`
Orden int `json:"orden"`
HealthURL string `json:"health_url"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
@@ -73,6 +78,7 @@ func CreateSaasProducto(c *fiber.Ctx) error {
ServicioID: req.ServicioID,
Activo: req.Activo,
Orden: req.Orden,
HealthURL: strings.TrimSpace(req.HealthURL),
}
if err := models.CreateSaasProducto(item); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -94,6 +100,7 @@ func UpdateSaasProducto(c *fiber.Ctx) error {
ServicioID *uint `json:"servicio_id"`
Activo bool `json:"activo"`
Orden int `json:"orden"`
HealthURL string `json:"health_url"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
@@ -107,6 +114,7 @@ func UpdateSaasProducto(c *fiber.Ctx) error {
ServicioID: req.ServicioID,
Activo: req.Activo,
Orden: req.Orden,
HealthURL: strings.TrimSpace(req.HealthURL),
}
item.ID = uint(id)
if err := models.UpdateSaasProducto(item); err != nil {
@@ -126,3 +134,33 @@ func DeleteSaasProducto(c *fiber.Ctx) error {
}
return c.JSON(fiber.Map{"message": "Producto SaaS eliminado"})
}
// HealthCheckSaas hace un GET a la HealthURL del producto y devuelve status + latencia.
func HealthCheckSaas(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ID inválido"})
}
item, err := models.GetSaasProductoByID(uint(id))
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no encontrado"})
}
if strings.TrimSpace(item.HealthURL) == "" {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"error": "Sin URL de health check configurada"})
}
client := &http.Client{Timeout: 10 * time.Second}
start := time.Now()
resp, reqErr := client.Get(item.HealthURL) //nolint:noctx
latency := time.Since(start).Milliseconds()
if reqErr != nil {
return c.JSON(fiber.Map{"ok": false, "error": reqErr.Error(), "latency_ms": latency})
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
return c.JSON(fiber.Map{
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"http_status": resp.StatusCode,
"body": string(body),
"latency_ms": latency,
})
}
+192 -14
View File
@@ -1,28 +1,206 @@
package controllers
import (
"bytes"
"encoding/json"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
type TelegramController struct {
Service *services.TelegramService
// TelegramIndex renderiza la vista de configuración de Telegram.
func TelegramIndex(c *fiber.Ctx) error {
return c.Render("telegram", fiber.Map{
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}, "layouts/main")
}
func NewTelegramController(service *services.TelegramService) *TelegramController {
return &TelegramController{Service: service}
}
func (tc *TelegramController) SendMessage(chatID interface{}, message string) error {
if chatID == "" || message == "" {
return fmt.Errorf("chat_id and message are required")
}
err := tc.Service.SendMessage(chatID, message)
// GetTelegramConfigs devuelve todas las configs en JSON.
func GetTelegramConfigs(c *fiber.Ctx) error {
items, err := models.GetAllTelegramConfigs()
if err != nil {
return fmt.Errorf("error sending message: %v", err)
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(items)
}
// CreateTelegramConfig crea una nueva configuración.
func CreateTelegramConfig(c *fiber.Ctx) error {
var m models.TelegramConfig
if err := c.BodyParser(&m); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
if strings.TrimSpace(m.Nombre) == "" || strings.TrimSpace(m.BotToken) == "" || strings.TrimSpace(m.ChatID) == "" {
return c.Status(400).JSON(fiber.Map{"error": "nombre, bot_token y chat_id son obligatorios"})
}
m.Activo = true
if err := models.CreateTelegramConfig(&m); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(m)
}
// UpdateTelegramConfig actualiza una configuración.
func UpdateTelegramConfig(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
var m models.TelegramConfig
if err := c.BodyParser(&m); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
m.ID = uint(id)
// Si no se envía bot_token, mantener el existente
if strings.TrimSpace(m.BotToken) == "" {
cfg, err := models.GetTelegramConfigByID(uint(id))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "no encontrado"})
}
m.BotToken = cfg.BotToken
}
if err := models.UpdateTelegramConfig(&m); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// DeleteTelegramConfig elimina una configuración.
func DeleteTelegramConfig(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
if err := models.DeleteTelegramConfig(uint(id)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// TestTelegramConfig envía un mensaje de prueba al bot configurado.
func TestTelegramConfig(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
cfg, err := models.GetTelegramConfigByID(uint(id))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Configuración no encontrada"})
}
msg := "✅ <b>Prueba de conexión</b>\nEsta es una notificación de prueba desde <b>u-site admin</b>."
sendErr := sendTelegramMessage(cfg.BotToken, cfg.ChatID, msg)
logEntry := &models.TelegramLog{
TelegramConfigID: cfg.ID,
Titulo: "Prueba manual",
Mensaje: msg,
Estado: "ok",
}
if sendErr != nil {
logEntry.Estado = "failed"
logEntry.ErrorMsg = sendErr.Error()
_ = models.CreateTelegramLog(logEntry)
return c.Status(422).JSON(fiber.Map{"ok": false, "error": sendErr.Error()})
}
_ = models.CreateTelegramLog(logEntry)
return c.JSON(fiber.Map{"ok": true, "message": "Mensaje enviado"})
}
// SendTelegramNotification envía un mensaje personalizado a una o varias configs.
// Body: { "config_ids": [1,2], "titulo": "...", "mensaje": "..." }
func SendTelegramNotification(c *fiber.Ctx) error {
type Req struct {
ConfigIDs []uint `json:"config_ids"`
Titulo string `json:"titulo"`
Mensaje string `json:"mensaje"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
if strings.TrimSpace(req.Mensaje) == "" {
return c.Status(400).JSON(fiber.Map{"error": "El mensaje no puede estar vacío"})
}
if len(req.ConfigIDs) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "Selecciona al menos un destino"})
}
text := req.Mensaje
if req.Titulo != "" {
text = fmt.Sprintf("<b>%s</b>\n\n%s", req.Titulo, req.Mensaje)
}
var enviados, fallidos int
for _, cid := range req.ConfigIDs {
cfg, err := models.GetTelegramConfigByID(cid)
if err != nil || !cfg.Activo {
fallidos++
continue
}
sendErr := sendTelegramMessage(cfg.BotToken, cfg.ChatID, text)
logEntry := &models.TelegramLog{
TelegramConfigID: cfg.ID,
Titulo: req.Titulo,
Mensaje: req.Mensaje,
Estado: "ok",
}
if sendErr != nil {
logEntry.Estado = "failed"
logEntry.ErrorMsg = sendErr.Error()
fallidos++
} else {
enviados++
}
_ = models.CreateTelegramLog(logEntry)
}
return c.JSON(fiber.Map{"ok": fallidos == 0, "enviados": enviados, "fallidos": fallidos})
}
// GetTelegramLogs devuelve el historial paginado de mensajes enviados.
func GetTelegramLogs(c *fiber.Ctx) error {
page, _ := strconv.Atoi(c.Query("page", "1"))
if page < 1 {
page = 1
}
limit := 30
offset := (page - 1) * limit
items, total, err := models.GetTelegramLogs(limit, offset)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"items": items,
"total": total,
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
"page": page,
})
}
// ─── helper interno ───────────────────────────────────────────────────────────
func sendTelegramMessage(botToken, chatID, text string) error {
if botToken == "" {
return fmt.Errorf("bot_token vacío")
}
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
payload := map[string]interface{}{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
}
body, _ := json.Marshal(payload)
resp, err := http.Post(apiURL, "application/json", bytes.NewReader(body)) //nolint:noctx
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Telegram respondió %d", resp.StatusCode)
}
return nil
}