Files
soft_usite/rest/controllers/umind_admin_controller.go
T
Lizandro GuarnizoandClaude Sonnet 5 8ef49c5169 feat: bucles de crecimiento y activación de uMind
Cuatro cosas que ya estaban a medio construir y no se usaban como canal.

1. El badge del widget ahora es un enlace con UTM. Cada cliente ya te
   estaba dando exposición en su sitio y no se capitalizaba. El host sale
   de la URL del propio script, así funciona igual en cualquier entorno.

2. Reporte del mes por agente en Excel: conversaciones atendidas, con qué
   las abrió el visitante, y el consumo desglosado. Es lo que el cliente
   necesita para justificar el gasto puertas adentro — un panel al que hay
   que entrar no sirve para eso, un archivo que se reenvía sí. Reusa el
   generador de xlsx del cronograma.

3. Aviso automático de agentes sin base de conocimiento (cron diario). Un
   agente sin fuentes responde de memoria e inventa datos, el cliente
   concluye que el producto no sirve y se va. Es el punto donde más gente
   se cae y se detecta solo. Se avisa UNA vez por agente, usando el log de
   auditoría como registro de envío para no necesitar tabla nueva ni
   convertir el recordatorio en spam.

4. Tarjeta de uMind en el dashboard del portal: atajo si el cliente ya lo
   tiene, oferta si no. Es el punto de contacto más barato que hay — ya
   entró, ya confía, y el cobro se suma a la factura que ya recibe. El
   mensaje lidera con notas de voz y fotos, que es lo más difícil de
   copiar de lo que tenemos.

La consulta de conversaciones agrupa en SQL en vez de traerse el historial
entero para agrupar en Go.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 22:40:28 -05:00

859 lines
32 KiB
Go

package controllers
import (
"bytes"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
)
// UmindIndex renderiza el panel de administración de uMind.
func UmindIndex(c *fiber.Ctx) error {
return c.Render("umind", fiber.Map{
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}, "layouts/main")
}
var umindColorHexRegex = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// umindColor valida el hex del color de marca del widget; si viene vacío o
// inválido cae al verde por defecto en vez de guardar basura (el valor se
// aplica tal cual como CSS custom property en el widget embebido).
func umindColor(color string) string {
color = strings.TrimSpace(color)
if umindColorHexRegex.MatchString(color) {
return color
}
return "#8eb02f"
}
// ─── Tenants ─────────────────────────────────────────────────────────────────
// Un tenant es el negocio/sitio dueño de los dominios permitidos — la config
// de IA, tono, tools, etc. viven en sus UmindAgente (ver más abajo).
func GetUmindTenants(c *fiber.Ctx) error {
page, _ := strconv.Atoi(c.Query("page", "1"))
if page < 1 {
page = 1
}
limit := 20
offset := (page - 1) * limit
items, total, err := models.GetAllUmindTenants(limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
items = tenantsVisibles(c, items)
if _, esStaff := middlewares.UmindScopeDe(c); !esStaff {
// Para el cliente la paginación global no tiene sentido (ve pocos
// tenants); el total pasa a ser el de lo que realmente puede ver.
total = int64(len(items))
}
return c.JSON(fiber.Map{
"items": items,
"total": total,
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
"page": page,
})
}
type umindTenantReq struct {
Nombre string `json:"nombre"`
DominiosPermitidos []string `json:"dominios_permitidos"`
Activo bool `json:"activo"`
ClienteID *uint `json:"cliente_id"`
PlanID *uint `json:"plan_id"`
}
func (r umindTenantReq) dominiosLimpios() []string {
var out []string
for _, d := range r.DominiosPermitidos {
d = strings.ToLower(strings.TrimSpace(d))
if d != "" {
out = append(out, d)
}
}
return out
}
func CreateUmindTenantHandler(c *fiber.Ctx) error {
var req umindTenantReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if strings.TrimSpace(req.Nombre) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"})
}
dominios := req.dominiosLimpios()
if len(dominios) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agrega al menos un dominio permitido"})
}
tenant := &models.UmindTenant{
Nombre: strings.TrimSpace(req.Nombre),
DominiosPermitidos: strings.Join(dominios, ","),
Activo: true,
CreadoPorID: extraerUserID(c),
ClienteID: req.ClienteID,
PlanID: req.PlanID,
}
if err := models.CreateUmindTenant(tenant); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": tenant.ID})
}
func UpdateUmindTenantHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoTenant(c, uint(id)); err != nil {
return err
}
var req umindTenantReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
dominios := req.dominiosLimpios()
updates := map[string]interface{}{
"nombre": strings.TrimSpace(req.Nombre),
"dominios_permitidos": strings.Join(dominios, ","),
"activo": req.Activo,
"cliente_id": req.ClienteID,
"plan_id": req.PlanID,
}
if err := models.UpdateUmindTenant(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DeleteUmindTenantHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoTenant(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindTenant(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Agentes ─────────────────────────────────────────────────────────────────
// Un tenant puede tener varios agentes independientes (ej. "Ventas",
// "Soporte"), cada uno con su propia config de IA, base de conocimiento,
// tools, canales y conexión de correo.
func GetUmindAgentesHandler(c *fiber.Ctx) error {
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
if err != nil || tenantID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
}
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
items, err := models.GetUmindAgentesByTenant(uint(tenantID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// El cupo viaja con la lista para que la pantalla pueda mostrar "1 de 1"
// en vez de dejar que el límite aparezca recién como un error al guardar.
// Sin plan no hay límite (ver verificarCupoAgentes), y eso también hay que
// poder verlo: si no, un tenant sin plan parece un límite que no funciona.
ids := make([]uint, len(items))
for i, a := range items {
ids[i] = a.ID
}
resp := fiber.Map{
"items": items,
"usados": len(items),
"resumen": models.GetResumenAgentes(ids),
}
if plan := models.GetPlanDeTenant(uint(tenantID)); plan != nil {
resp["plan"] = fiber.Map{"nombre": plan.Nombre, "max_agentes": plan.MaxAgentes}
}
return c.JSON(resp)
}
type umindAgenteReq struct {
TenantID uint `json:"tenant_id"`
Nombre string `json:"nombre"`
AiConfigID *uint `json:"ai_config_id"`
Tono string `json:"tono"`
MensajeBienvenida string `json:"mensaje_bienvenida"`
Color string `json:"color"`
Activo bool `json:"activo"`
}
func CreateUmindAgenteHandler(c *fiber.Ctx) error {
var req umindAgenteReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if req.TenantID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
}
if strings.TrimSpace(req.Nombre) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"})
}
if err := accesoTenant(c, req.TenantID); err != nil {
return err
}
if _, err := models.GetUmindTenantByID(req.TenantID); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant no encontrado"})
}
if err := verificarCupoAgentes(c, req.TenantID); err != nil {
return err
}
agente := &models.UmindAgente{
TenantID: req.TenantID,
Nombre: strings.TrimSpace(req.Nombre),
AiConfigID: req.AiConfigID,
Tono: req.Tono,
MensajeBienvenida: req.MensajeBienvenida,
Color: umindColor(req.Color),
Activo: true,
}
if err := models.CreateUmindAgente(agente); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": agente.ID, "site_key": agente.SiteKey})
}
func UpdateUmindAgenteHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if _, err := accesoAgente(c, uint(id)); err != nil {
return err
}
var req umindAgenteReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
updates := map[string]interface{}{
"nombre": strings.TrimSpace(req.Nombre),
"ai_config_id": req.AiConfigID,
"tono": req.Tono,
"mensaje_bienvenida": req.MensajeBienvenida,
"color": umindColor(req.Color),
"activo": req.Activo,
}
if err := models.UpdateUmindAgente(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DeleteUmindAgenteHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if _, err := accesoAgente(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindAgente(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Documentos / ingesta ────────────────────────────────────────────────────
func GetUmindDocumentosHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindDocumentosByAgente(uint(agenteID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"items": items})
}
// CreateUmindDocumentoHandler registra una nueva fuente (por ahora solo URL a
// crawlear) y dispara la ingesta en segundo plano — puede tardar varios
// segundos/minutos según cuántas páginas tenga el sitio.
func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
var req struct {
AgenteID uint `json:"agente_id"`
URL string `json:"url"`
MaxPaginas int `json:"max_paginas"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if req.AgenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, req.AgenteID); err != nil {
return err
}
if strings.TrimSpace(req.URL) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "url requerida"})
}
if _, err := models.GetUmindAgenteByID(req.AgenteID); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "agente no encontrado"})
}
doc := &models.UmindDocumento{
AgenteID: req.AgenteID,
Tipo: "url",
Origen: strings.TrimSpace(req.URL),
Estado: "procesando",
}
if err := models.CreateUmindDocumento(doc); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
go services.IngestarAgente(req.AgenteID, doc.ID, doc.Origen, req.MaxPaginas)
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "id": doc.ID, "estado": "procesando"})
}
func DeleteUmindDocumentoHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoDocumento(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindDocumento(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Conversaciones ───────────────────────────────────────────────────────────
func GetUmindSesionesHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindSesiones(uint(agenteID), 50)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"items": items})
}
func GetUmindHistorialHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
sessionID := c.Query("session_id")
if sessionID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "session_id requerido"})
}
items, err := models.GetUmindHistorial(uint(agenteID), sessionID, 200)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"items": items})
}
// ─── Tools custom (webhooks) ───────────────────────────────────────────────
var umindNombreToolRegex = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`)
func GetUmindHerramientasHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindHerramientasByAgente(uint(agenteID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// No se devuelve el secreto cifrado ni el descifrado — solo si hay uno configurado.
out := make([]fiber.Map, len(items))
for i, h := range items {
out[i] = fiber.Map{
"ID": h.ID, "agente_id": h.AgenteID, "nombre": h.Nombre, "descripcion": h.Descripcion,
"parametros_json": h.ParametrosJSON, "url": h.URL, "auth_header_nombre": h.AuthHeaderNombre,
"auth_configurado": h.AuthHeaderValorEnc != "", "activa": h.Activa,
}
}
return c.JSON(fiber.Map{"items": out})
}
type umindHerramientaReq struct {
AgenteID uint `json:"agente_id"`
Nombre string `json:"nombre"`
Descripcion string `json:"descripcion"`
Parametros []models.UmindHerramientaParametro `json:"parametros"`
URL string `json:"url"`
AuthHeaderNombre string `json:"auth_header_nombre"`
AuthHeaderValor *string `json:"auth_header_valor"` // nil = no tocar (en updates)
Activa bool `json:"activa"`
}
func CreateUmindHerramientaHandler(c *fiber.Ctx) error {
var req umindHerramientaReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if req.AgenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, req.AgenteID); err != nil {
return err
}
if !umindNombreToolRegex.MatchString(req.Nombre) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el nombre debe ser minúsculas/números/guion_bajo, empezar con letra (3-64 caracteres)"})
}
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.URL)), "https://") {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "la URL debe ser https"})
}
parametrosJSON, err := models.ParametrosToJSON(req.Parametros)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "parametros inválidos"})
}
var authEnc string
if req.AuthHeaderValor != nil && *req.AuthHeaderValor != "" {
authEnc, err = services.CifrarSecretoUmind(*req.AuthHeaderValor)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
}
h := &models.UmindHerramienta{
AgenteID: req.AgenteID, Nombre: req.Nombre, Descripcion: strings.TrimSpace(req.Descripcion),
ParametrosJSON: parametrosJSON, URL: strings.TrimSpace(req.URL),
AuthHeaderNombre: strings.TrimSpace(req.AuthHeaderNombre), AuthHeaderValorEnc: authEnc, Activa: true,
}
if err := models.CreateUmindHerramienta(h); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": h.ID})
}
func UpdateUmindHerramientaHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoHerramienta(c, uint(id)); err != nil {
return err
}
var req umindHerramientaReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if !umindNombreToolRegex.MatchString(req.Nombre) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el nombre debe ser minúsculas/números/guion_bajo, empezar con letra (3-64 caracteres)"})
}
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.URL)), "https://") {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "la URL debe ser https"})
}
parametrosJSON, err := models.ParametrosToJSON(req.Parametros)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "parametros inválidos"})
}
updates := map[string]interface{}{
"nombre": req.Nombre, "descripcion": strings.TrimSpace(req.Descripcion),
"parametros_json": parametrosJSON, "url": strings.TrimSpace(req.URL),
"auth_header_nombre": strings.TrimSpace(req.AuthHeaderNombre), "activa": req.Activa,
}
if req.AuthHeaderValor != nil {
if *req.AuthHeaderValor == "" {
updates["auth_header_valor_enc"] = ""
} else {
enc, err := services.CifrarSecretoUmind(*req.AuthHeaderValor)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
updates["auth_header_valor_enc"] = enc
}
}
if err := models.UpdateUmindHerramienta(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DeleteUmindHerramientaHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoHerramienta(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindHerramienta(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Canales (Telegram / WhatsApp) ─────────────────────────────────────────
func GetUmindCanalesHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindCanalesByAgente(uint(agenteID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
out := make([]fiber.Map, len(items))
for i, canal := range items {
webhookURL := ""
if canal.Tipo == "telegram" {
webhookURL = fmt.Sprintf("%s/webhooks/umind-telegram/%s", app.Http.Server.Url, canal.WebhookSecret)
} else if canal.Tipo == "whatsapp" {
webhookURL = fmt.Sprintf("%s/webhooks/umind-whatsapp/%s", app.Http.Server.Url, canal.WebhookSecret)
}
out[i] = fiber.Map{
"ID": canal.ID, "agente_id": canal.AgenteID, "tipo": canal.Tipo, "activo": canal.Activo,
"webhook_url": webhookURL, "ultimo_error": canal.UltimoError,
"usar_whisper_audio": canal.UsarWhisperAudio, "usar_ocr_imagenes": canal.UsarOcrImagenes,
}
}
return c.JSON(fiber.Map{"items": out})
}
type umindCanalReq struct {
AgenteID uint `json:"agente_id"`
Tipo string `json:"tipo"`
Credenciales map[string]string `json:"credenciales"`
Activo bool `json:"activo"`
UsarWhisperAudio bool `json:"usar_whisper_audio"`
UsarOcrImagenes bool `json:"usar_ocr_imagenes"`
}
func CreateUmindCanalHandler(c *fiber.Ctx) error {
var req umindCanalReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if req.AgenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, req.AgenteID); err != nil {
return err
}
switch req.Tipo {
case "telegram":
if strings.TrimSpace(req.Credenciales["bot_token"]) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bot_token requerido"})
}
case "whatsapp":
for _, k := range []string{"phone_number_id", "access_token", "app_secret", "verify_token"} {
if strings.TrimSpace(req.Credenciales[k]) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": k + " requerido"})
}
}
default:
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tipo debe ser telegram o whatsapp"})
}
credencialesEnc, err := services.CifrarCredencialesCanal(req.Credenciales)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
canal := &models.UmindCanal{
AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc,
UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes,
}
if err := models.CreateUmindCanal(canal); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if req.Tipo == "telegram" {
webhookURL := fmt.Sprintf("%s/webhooks/umind-telegram/%s", app.Http.Server.Url, canal.WebhookSecret)
if err := services.RegistrarWebhookTelegram(req.Credenciales["bot_token"], webhookURL); err != nil {
models.UpdateUmindCanal(canal.ID, map[string]interface{}{"ultimo_error": err.Error()})
}
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": canal.ID, "webhook_secret": canal.WebhookSecret})
}
func UpdateUmindCanalHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoCanal(c, uint(id)); err != nil {
return err
}
var req umindCanalReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
updates := map[string]interface{}{"activo": req.Activo, "usar_whisper_audio": req.UsarWhisperAudio, "usar_ocr_imagenes": req.UsarOcrImagenes}
if len(req.Credenciales) > 0 {
enc, err := services.CifrarCredencialesCanal(req.Credenciales)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
updates["credenciales_enc"] = enc
updates["ultimo_error"] = ""
}
if err := models.UpdateUmindCanal(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DeleteUmindCanalHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoCanal(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindCanal(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Chat de prueba ─────────────────────────────────────────────────────────
// UmindChatPruebaHandler deja que el staff pruebe un agente puntual directo
// desde el panel, sin pasar por site_key/dominio (ya está gateado por la
// sesión con la que se llega acá).
func UmindChatPruebaHandler(c *fiber.Ctx) error {
var req struct {
AgenteID uint `json:"agente_id"`
SessionID string `json:"session_id"`
Mensaje string `json:"mensaje"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if strings.TrimSpace(req.Mensaje) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "mensaje requerido"})
}
agente, errAcceso := accesoAgente(c, req.AgenteID)
if errAcceso != nil {
return errAcceso
}
sessionID := strings.TrimSpace(req.SessionID)
if sessionID == "" {
sessionID = "staff-preview:" + strconv.FormatUint(uint64(extraerUserID(c)), 10)
}
respuesta, err := services.ProcessWidgetMessage(agente, sessionID, strings.TrimSpace(req.Mensaje))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"session_id": sessionID, "respuesta": respuesta})
}
// ─── Auditoría ──────────────────────────────────────────────────────────────
// GetUmindEventosHandler expone el log de auditoría técnica de un agente —
// errores/eventos que antes solo se podían ver pidiendo los logs del
// servidor (fallos de AI, tools, correo, canales).
func GetUmindEventosHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindEventosByAgente(uint(agenteID), 100)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"items": items})
}
// ─── Consumo ────────────────────────────────────────────────────────────────
// GetUmindUsoHandler devuelve el consumo de un tenant en un rango de fechas:
// el resumen agregado por tipo (lo que se factura) y el detalle línea a línea.
// Sin fechas, el rango es el mes en curso.
func GetUmindUsoHandler(c *fiber.Ctx) error {
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
if err != nil || tenantID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
}
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
resumen, err := models.GetResumenUso(uint(tenantID), desde, hasta)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
detalle, err := models.GetUsoUmind(uint(tenantID), desde, hasta, c.Query("tipo"))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
pendiente, _ := models.SumarUsoPendiente(uint(tenantID))
total := 0.0
for _, r := range resumen {
total += r.Costo
}
out := fiber.Map{
"desde": desde.Format("2006-01-02"), "hasta": hasta.Format("2006-01-02"),
"resumen": resumen, "detalle": detalle,
"total_periodo": total, "pendiente_facturar": pendiente,
}
if plan := models.GetPlanDeTenant(uint(tenantID)); plan != nil {
out["plan"] = fiber.Map{
"nombre": plan.Nombre, "moneda": plan.Moneda,
"precio_mensual": plan.PrecioMensual, "tope_consumo_mensual": plan.TopeConsumoMensual,
"max_agentes": plan.MaxAgentes,
}
}
return c.JSON(out)
}
// rangoFechas parsea el filtro del panel. Ante una fecha inválida cae al mes
// en curso en vez de devolver error: es un filtro de lectura, no vale la pena
// romperle la pantalla al usuario por un query param mal escrito.
func rangoFechas(desdeStr, hastaStr string) (time.Time, time.Time) {
ahora := time.Now()
desde := time.Date(ahora.Year(), ahora.Month(), 1, 0, 0, 0, 0, ahora.Location())
hasta := desde.AddDate(0, 1, 0)
if d, err := time.Parse("2006-01-02", desdeStr); err == nil {
desde = d
}
if h, err := time.Parse("2006-01-02", hastaStr); err == nil {
hasta = h.AddDate(0, 0, 1) // inclusivo: "hasta el 31" incluye el 31 entero
}
if !hasta.After(desde) {
hasta = desde.AddDate(0, 1, 0)
}
return desde, hasta
}
// GetUmindReporteHandler entrega el reporte de actividad de un agente en
// Excel. Existe por una razón concreta: el cliente necesita algo que mostrar
// puertas adentro para justificar el gasto. Un panel que solo se ve entrando
// al portal no sirve para eso; un archivo que se reenvía, sí.
// Ruta: GET /umind/reporte.xlsx?agente_id=&desde=&hasta=
func GetUmindReporteHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).SendString("agente_id requerido")
}
agente, errAcceso := accesoAgente(c, uint(agenteID))
if errAcceso != nil {
return errAcceso
}
desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
negocio := ""
if t, err := models.GetUmindTenantByID(agente.TenantID); err == nil {
negocio = t.Nombre
}
sesiones, _ := models.GetUmindSesionesRango(agente.ID, desde, hasta)
resumen, _ := models.GetResumenUso(agente.TenantID, desde, hasta)
filas := [][]string{
{"Reporte de actividad"},
{"Negocio", negocio},
{"Agente", agente.Nombre},
{"Período", desde.Format("02/01/2006") + " al " + hasta.Format("02/01/2006")},
{},
{"Conversaciones atendidas", strconv.Itoa(len(sesiones))},
{},
{"Consumo del período"},
{"Tipo", "Cantidad", "Unidad", "Usos"},
}
etiquetas := map[string]string{
models.UsoTipoIA: "Inteligencia artificial",
models.UsoTipoOCR: "Lectura de imágenes",
models.UsoTipoWhisper: "Transcripción de audio",
}
for _, r := range resumen {
nombre := etiquetas[r.Tipo]
if nombre == "" {
nombre = r.Tipo
}
filas = append(filas, []string{
nombre,
strconv.FormatFloat(r.Cantidad, 'f', 0, 64),
r.Unidad,
strconv.FormatInt(r.Eventos, 10),
})
}
filas = append(filas, []string{}, []string{"Conversaciones"}, []string{"Fecha", "Mensajes", "Primer mensaje del visitante"})
for _, s := range sesiones {
filas = append(filas, []string{
s.Inicio.Format("02/01/2006 15:04"),
strconv.FormatInt(s.Mensajes, 10),
s.PrimerMensaje,
})
}
nombreArchivo := fmt.Sprintf("reporte-%s-%s.xlsx", slugSimple(agente.Nombre), hasta.Format("2006-01"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", `attachment; filename="`+nombreArchivo+`"`)
var buf bytes.Buffer
if err := services.EscribirXLSX(&buf, "Reporte", filas); err != nil {
return c.Status(500).SendString("no se pudo generar el reporte")
}
return c.Send(buf.Bytes())
}
// slugSimple deja solo minúsculas, números y guiones: un nombre de agente con
// acentos o espacios rompe el filename en algunos navegadores.
func slugSimple(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == ' ' || r == '-' || r == '_':
b.WriteRune('-')
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "agente"
}
return out
}