Se podían crear 2 agentes con un plan de 1 sin que nada lo explicara. El
límite del backend sí funciona, pero solo aplica si el tenant tiene un
plan asignado — y hasta 115f167 los campos Cliente y Plan no aparecían en
el formulario del tenant, así que en la práctica quedaban todos sin plan
y sin límite.
El problema de fondo era que ese estado no se veía por ningún lado: un
tenant sin plan se comportaba igual que un límite roto.
- La lista de agentes devuelve también el plan y cuántos hay usados.
- La pantalla muestra "Básico · 1 de 1", "Pro · ilimitado" o "sin plan ·
sin límite", y en este último caso explica que hay que asignarle uno.
- El botón + Nuevo agente se deshabilita al llegar al tope, en vez de
dejar que el límite aparezca recién como un error al guardar.
El backend sigue siendo la autoridad: esto es feedback, no la validación.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
762 lines
29 KiB
Go
762 lines
29 KiB
Go
package controllers
|
|
|
|
import (
|
|
"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
|
|
}
|
|
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.
|
|
resp := fiber.Map{"items": items, "usados": len(items)}
|
|
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
|
|
}
|