From f3f2f421d67ceb4c3ad39e13faab1c79e63760af Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:30:06 -0500 Subject: [PATCH] feat: uMind pasa a multi-agente por tenant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un tenant (negocio/sitio, dueño de los dominios permitidos) puede tener varios UmindAgente independientes (ej. "Ventas", "Soporte"), cada uno con su propia config de IA, tono, base de conocimiento, tools, canales y conexión de correo. El site_key también pasa a ser por agente, así cada uno tiene su propio + + diff --git a/pkg/models/umind.go b/pkg/models/umind.go index 837f0f9..a28c558 100644 --- a/pkg/models/umind.go +++ b/pkg/models/umind.go @@ -11,29 +11,25 @@ import ( "gorm.io/gorm" ) -// UmindTenant representa un sitio/cliente que tiene el widget de uMind -// instalado. SiteKey es pública (va en el - + +
diff --git a/rest/controllers/api/umind_canal_webhook_controller.go b/rest/controllers/api/umind_canal_webhook_controller.go index 19f3a41..2f1eb49 100644 --- a/rest/controllers/api/umind_canal_webhook_controller.go +++ b/rest/controllers/api/umind_canal_webhook_controller.go @@ -24,7 +24,7 @@ type umindTgUpdate struct { Message *umindTgMessage `json:"message"` } -// UmindTelegramWebhook recibe updates del bot de Telegram de un tenant. +// UmindTelegramWebhook recibe updates del bot de Telegram de un agente. // Ruta: POST /webhooks/umind-telegram/:webhook_secret // El webhook_secret es un identificador nuestro (no el bot token real) — // ver el comentario en models.UmindCanal. diff --git a/rest/controllers/api/umind_widget_controller.go b/rest/controllers/api/umind_widget_controller.go index 37606cb..d44ee81 100644 --- a/rest/controllers/api/umind_widget_controller.go +++ b/rest/controllers/api/umind_widget_controller.go @@ -10,6 +10,14 @@ import ( "github.com/sujit-baniya/fiber-boilerplate/pkg/services" ) +func agenteDeContexto(c *fiber.Ctx) (*models.UmindAgente, error) { + agente, ok := c.Locals("umind_agente").(*models.UmindAgente) + if !ok || agente == nil { + return nil, fiber.NewError(fiber.StatusUnauthorized, "no autenticado") + } + return agente, nil +} + func tenantDeContexto(c *fiber.Ctx) (*models.UmindTenant, error) { tenant, ok := c.Locals("umind_tenant").(*models.UmindTenant) if !ok || tenant == nil { @@ -25,18 +33,24 @@ func nuevaSessionID() string { } // UmindWidgetInit devuelve una sesión nueva y el mensaje de bienvenida del -// tenant, sin gastar una llamada al LLM solo para saludar. +// agente, sin gastar una llamada al LLM solo para saludar. El nombre que se +// muestra es el del negocio (tenant dueño del agente), no la etiqueta +// interna del agente. // Ruta: GET /widget/:site_key/init func UmindWidgetInit(c *fiber.Ctx) error { + agente, err := agenteDeContexto(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()}) + } tenant, err := tenantDeContexto(c) if err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()}) } - bienvenida := strings.TrimSpace(tenant.MensajeBienvenida) + bienvenida := strings.TrimSpace(agente.MensajeBienvenida) if bienvenida == "" { bienvenida = "¡Hola! ¿En qué puedo ayudarte?" } - color := strings.TrimSpace(tenant.Color) + color := strings.TrimSpace(agente.Color) if color == "" { color = "#8eb02f" } @@ -51,7 +65,7 @@ func UmindWidgetInit(c *fiber.Ctx) error { // UmindWidgetMensaje procesa un mensaje del visitante y devuelve la respuesta del agente. // Ruta: POST /widget/:site_key/mensaje func UmindWidgetMensaje(c *fiber.Ctx) error { - tenant, err := tenantDeContexto(c) + agente, err := agenteDeContexto(c) if err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()}) } @@ -75,7 +89,7 @@ func UmindWidgetMensaje(c *fiber.Ctx) error { req.Mensaje = req.Mensaje[:4000] } - respuesta, err := services.ProcessWidgetMessage(tenant, sessionID, strings.TrimSpace(req.Mensaje)) + respuesta, err := services.ProcessWidgetMessage(agente, sessionID, strings.TrimSpace(req.Mensaje)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": true, "message": err.Error()}) } diff --git a/rest/controllers/umind_admin_controller.go b/rest/controllers/umind_admin_controller.go index d72431b..bc7fd24 100644 --- a/rest/controllers/umind_admin_controller.go +++ b/rest/controllers/umind_admin_controller.go @@ -21,7 +21,22 @@ func UmindIndex(c *fiber.Ctx) error { }, "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")) @@ -45,26 +60,9 @@ func GetUmindTenants(c *fiber.Ctx) error { type umindTenantReq struct { Nombre string `json:"nombre"` DominiosPermitidos []string `json:"dominios_permitidos"` - AiConfigID *uint `json:"ai_config_id"` - Tono string `json:"tono"` - MensajeBienvenida string `json:"mensaje_bienvenida"` - Color string `json:"color"` Activo bool `json:"activo"` } -var umindColorHexRegex = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) - -// colorTenant 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 colorTenant(color string) string { - color = strings.TrimSpace(color) - if umindColorHexRegex.MatchString(color) { - return color - } - return "#8eb02f" -} - func (r umindTenantReq) dominiosLimpios() []string { var out []string for _, d := range r.DominiosPermitidos { @@ -92,17 +90,13 @@ func CreateUmindTenantHandler(c *fiber.Ctx) error { tenant := &models.UmindTenant{ Nombre: strings.TrimSpace(req.Nombre), DominiosPermitidos: strings.Join(dominios, ","), - AiConfigID: req.AiConfigID, - Tono: req.Tono, - MensajeBienvenida: req.MensajeBienvenida, - Color: colorTenant(req.Color), Activo: true, CreadoPorID: extraerUserID(c), } 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, "site_key": tenant.SiteKey}) + return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": tenant.ID}) } func UpdateUmindTenantHandler(c *fiber.Ctx) error { @@ -118,10 +112,6 @@ func UpdateUmindTenantHandler(c *fiber.Ctx) error { updates := map[string]interface{}{ "nombre": strings.TrimSpace(req.Nombre), "dominios_permitidos": strings.Join(dominios, ","), - "ai_config_id": req.AiConfigID, - "tono": req.Tono, - "mensaje_bienvenida": req.MensajeBienvenida, - "color": colorTenant(req.Color), "activo": req.Activo, } if err := models.UpdateUmindTenant(uint(id), updates); err != nil { @@ -141,14 +131,105 @@ func DeleteUmindTenantHandler(c *fiber.Ctx) error { return c.JSON(fiber.Map{"ok": true}) } -// ─── Documentos / ingesta ──────────────────────────────────────────────────── +// ─── 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 GetUmindDocumentosHandler(c *fiber.Ctx) error { +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"}) } - items, err := models.GetUmindDocumentosByTenant(uint(tenantID)) + items, err := models.GetUmindAgentesByTenant(uint(tenantID)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"items": items}) +} + +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 := models.GetUmindTenantByID(req.TenantID); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant no encontrado"}) + } + + 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"}) + } + 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 := 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"}) + } + items, err := models.GetUmindDocumentosByAgente(uint(agenteID)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } @@ -160,25 +241,25 @@ func GetUmindDocumentosHandler(c *fiber.Ctx) error { // segundos/minutos según cuántas páginas tenga el sitio. func CreateUmindDocumentoHandler(c *fiber.Ctx) error { var req struct { - TenantID uint `json:"tenant_id"` + 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.TenantID == 0 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"}) + if req.AgenteID == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"}) } if strings.TrimSpace(req.URL) == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "url requerida"}) } - if _, err := models.GetUmindTenantByID(req.TenantID); err != nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant no encontrado"}) + if _, err := models.GetUmindAgenteByID(req.AgenteID); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "agente no encontrado"}) } doc := &models.UmindDocumento{ - TenantID: req.TenantID, + AgenteID: req.AgenteID, Tipo: "url", Origen: strings.TrimSpace(req.URL), Estado: "procesando", @@ -187,7 +268,7 @@ func CreateUmindDocumentoHandler(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - go services.IngestarTenant(req.TenantID, doc.ID, doc.Origen, req.MaxPaginas) + 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"}) } @@ -206,11 +287,11 @@ func DeleteUmindDocumentoHandler(c *fiber.Ctx) error { // ─── Conversaciones ─────────────────────────────────────────────────────────── func GetUmindSesionesHandler(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"}) + 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"}) } - items, err := models.GetUmindSesiones(uint(tenantID), 50) + items, err := models.GetUmindSesiones(uint(agenteID), 50) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } @@ -218,15 +299,15 @@ func GetUmindSesionesHandler(c *fiber.Ctx) error { } func GetUmindHistorialHandler(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"}) + 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"}) } sessionID := c.Query("session_id") if sessionID == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "session_id requerido"}) } - items, err := models.GetUmindHistorial(uint(tenantID), sessionID, 200) + items, err := models.GetUmindHistorial(uint(agenteID), sessionID, 200) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } @@ -238,11 +319,11 @@ func GetUmindHistorialHandler(c *fiber.Ctx) error { var umindNombreToolRegex = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`) func GetUmindHerramientasHandler(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"}) + 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"}) } - items, err := models.GetUmindHerramientasByTenant(uint(tenantID)) + items, err := models.GetUmindHerramientasByAgente(uint(agenteID)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } @@ -250,7 +331,7 @@ func GetUmindHerramientasHandler(c *fiber.Ctx) error { out := make([]fiber.Map, len(items)) for i, h := range items { out[i] = fiber.Map{ - "ID": h.ID, "tenant_id": h.TenantID, "nombre": h.Nombre, "descripcion": h.Descripcion, + "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, } @@ -259,7 +340,7 @@ func GetUmindHerramientasHandler(c *fiber.Ctx) error { } type umindHerramientaReq struct { - TenantID uint `json:"tenant_id"` + AgenteID uint `json:"agente_id"` Nombre string `json:"nombre"` Descripcion string `json:"descripcion"` Parametros []models.UmindHerramientaParametro `json:"parametros"` @@ -274,8 +355,8 @@ func CreateUmindHerramientaHandler(c *fiber.Ctx) error { 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 req.AgenteID == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"}) } 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)"}) @@ -297,7 +378,7 @@ func CreateUmindHerramientaHandler(c *fiber.Ctx) error { } h := &models.UmindHerramienta{ - TenantID: req.TenantID, Nombre: req.Nombre, Descripcion: strings.TrimSpace(req.Descripcion), + 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, } @@ -363,11 +444,11 @@ func DeleteUmindHerramientaHandler(c *fiber.Ctx) error { // ─── Canales (Telegram / WhatsApp) ───────────────────────────────────────── func GetUmindCanalesHandler(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"}) + 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"}) } - items, err := models.GetUmindCanalesByTenant(uint(tenantID)) + items, err := models.GetUmindCanalesByAgente(uint(agenteID)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } @@ -380,7 +461,7 @@ func GetUmindCanalesHandler(c *fiber.Ctx) error { webhookURL = fmt.Sprintf("%s/webhooks/umind-whatsapp/%s", app.Http.Server.Url, canal.WebhookSecret) } out[i] = fiber.Map{ - "ID": canal.ID, "tenant_id": canal.TenantID, "tipo": canal.Tipo, "activo": canal.Activo, + "ID": canal.ID, "agente_id": canal.AgenteID, "tipo": canal.Tipo, "activo": canal.Activo, "webhook_url": webhookURL, "ultimo_error": canal.UltimoError, } } @@ -388,7 +469,7 @@ func GetUmindCanalesHandler(c *fiber.Ctx) error { } type umindCanalReq struct { - TenantID uint `json:"tenant_id"` + AgenteID uint `json:"agente_id"` Tipo string `json:"tipo"` Credenciales map[string]string `json:"credenciales"` Activo bool `json:"activo"` @@ -399,8 +480,8 @@ func CreateUmindCanalHandler(c *fiber.Ctx) error { 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 req.AgenteID == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"}) } switch req.Tipo { case "telegram": @@ -421,7 +502,7 @@ func CreateUmindCanalHandler(c *fiber.Ctx) error { if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - canal := &models.UmindCanal{TenantID: req.TenantID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc} + canal := &models.UmindCanal{AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc} if err := models.CreateUmindCanal(canal); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } @@ -472,12 +553,12 @@ func DeleteUmindCanalHandler(c *fiber.Ctx) error { // ─── Chat de prueba ───────────────────────────────────────────────────────── -// UmindChatPruebaHandler deja que el staff pruebe el agente de un tenant -// directo desde el panel, sin pasar por site_key/dominio (ya está gateado -// por la sesión con la que se llega acá). +// 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 { - TenantID uint `json:"tenant_id"` + AgenteID uint `json:"agente_id"` SessionID string `json:"session_id"` Mensaje string `json:"mensaje"` } @@ -487,15 +568,15 @@ func UmindChatPruebaHandler(c *fiber.Ctx) error { if strings.TrimSpace(req.Mensaje) == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "mensaje requerido"}) } - tenant, err := models.GetUmindTenantByID(req.TenantID) + agente, err := models.GetUmindAgenteByID(req.AgenteID) if err != nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant no encontrado"}) + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "agente no encontrado"}) } sessionID := strings.TrimSpace(req.SessionID) if sessionID == "" { sessionID = "staff-preview:" + strconv.FormatUint(uint64(extraerUserID(c)), 10) } - respuesta, err := services.ProcessWidgetMessage(tenant, sessionID, strings.TrimSpace(req.Mensaje)) + respuesta, err := services.ProcessWidgetMessage(agente, sessionID, strings.TrimSpace(req.Mensaje)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } diff --git a/rest/controllers/umind_oauth_controller.go b/rest/controllers/umind_oauth_controller.go index bb1f45a..e5f4e01 100644 --- a/rest/controllers/umind_oauth_controller.go +++ b/rest/controllers/umind_oauth_controller.go @@ -11,20 +11,20 @@ import ( ) // GetUmindConexionesHandler lista las cuentas de correo conectadas de un -// tenant, sin exponer los tokens (ni cifrados ni en claro). +// agente, sin exponer los tokens (ni cifrados ni en claro). func GetUmindConexionesHandler(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"}) + 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"}) } - items, err := models.GetUmindConexionesByTenant(uint(tenantID)) + items, err := models.GetUmindConexionesByAgente(uint(agenteID)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } out := make([]fiber.Map, len(items)) for i, cx := range items { out[i] = fiber.Map{ - "ID": cx.ID, "tenant_id": cx.TenantID, "proveedor": cx.Proveedor, + "ID": cx.ID, "agente_id": cx.AgenteID, "proveedor": cx.Proveedor, "email": cx.Email, "activo": cx.Activo, "expira_en": cx.ExpiraEn, } } @@ -32,18 +32,18 @@ func GetUmindConexionesHandler(c *fiber.Ctx) error { } // UmindConectarHandler redirige al staff a la pantalla de consentimiento de -// Google/Microsoft. Ruta: GET /app/umind/conexiones/conectar?tenant_id=&proveedor= +// Google/Microsoft. Ruta: GET /app/umind/conexiones/conectar?agente_id=&proveedor= func UmindConectarHandler(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"}) + 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"}) } proveedor := c.Query("proveedor") - if _, err := models.GetUmindTenantByID(uint(tenantID)); err != nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant no encontrado"}) + if _, err := models.GetUmindAgenteByID(uint(agenteID)); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "agente no encontrado"}) } - url, err := services.IniciarConexionOAuth(proveedor, uint(tenantID)) + url, err := services.IniciarConexionOAuth(proveedor, uint(agenteID)) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } @@ -69,7 +69,11 @@ func UmindOAuthCallbackHandler(c *fiber.Ctx) error { log.Printf("[UMIND_OAUTH] error completando conexión (%s): %v", proveedor, err) return c.Redirect("/orchestrator/?oauth_error=1", fiber.StatusFound) } - return c.Redirect(fmt.Sprintf("/orchestrator/tenants/%d?tab=conexiones", conexion.TenantID), fiber.StatusFound) + agente, err := models.GetUmindAgenteByID(conexion.AgenteID) + if err != nil { + return c.Redirect("/orchestrator/?oauth_error=1", fiber.StatusFound) + } + return c.Redirect(fmt.Sprintf("/orchestrator/tenants/%d/agentes/%d?tab=conexiones", agente.TenantID, agente.ID), fiber.StatusFound) } func DeleteUmindConexionHandler(c *fiber.Ctx) error { diff --git a/rest/middlewares/auth_umind_widget.go b/rest/middlewares/auth_umind_widget.go index 6613264..81f7aa9 100644 --- a/rest/middlewares/auth_umind_widget.go +++ b/rest/middlewares/auth_umind_widget.go @@ -8,20 +8,25 @@ import ( "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) -// AuthUmindWidget resuelve el tenant a partir de la site_key en la URL y -// valida que la petición venga de uno de sus dominios permitidos (Origin, o -// Referer si el navegador no manda Origin). La site_key no es un secreto — -// cualquiera puede verla en el HTML público del sitio — la protección real es -// el chequeo de dominio, igual que una site key de reCAPTCHA/Analytics. +// AuthUmindWidget resuelve el agente a partir de la site_key en la URL y +// valida que la petición venga de uno de los dominios permitidos del tenant +// dueño de ese agente (Origin, o Referer si el navegador no manda Origin). +// La site_key no es un secreto — cualquiera puede verla en el HTML público +// del sitio — la protección real es el chequeo de dominio, igual que una +// site key de reCAPTCHA/Analytics. func AuthUmindWidget(c *fiber.Ctx) error { siteKey := c.Params("site_key") if siteKey == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "site_key requerida"}) } - tenant, err := models.GetUmindTenantBySiteKey(siteKey) + agente, err := models.GetUmindAgenteBySiteKey(siteKey) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": true, "message": "sitio no encontrado"}) } + tenant, err := models.GetUmindTenantByID(agente.TenantID) + if err != nil || !tenant.Activo { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": true, "message": "sitio no encontrado"}) + } origen := c.Get("Origin") if origen == "" { @@ -36,6 +41,7 @@ func AuthUmindWidget(c *fiber.Ctx) error { c.Set("Access-Control-Allow-Origin", origen) c.Set("Vary", "Origin") } + c.Locals("umind_agente", agente) c.Locals("umind_tenant", tenant) return c.Next() } diff --git a/rest/routes/user.go b/rest/routes/user.go index 472dbbf..5cd638b 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -360,6 +360,10 @@ func UserRoutes(app fiber.Router) { protected.Post("/umind/tenants", middlewares.SoloAdmin, controllers.CreateUmindTenantHandler) protected.Put("/umind/tenants/:id", middlewares.SoloAdmin, controllers.UpdateUmindTenantHandler) protected.Delete("/umind/tenants/:id", middlewares.SoloAdmin, controllers.DeleteUmindTenantHandler) + protected.Get("/umind/agentes", controllers.GetUmindAgentesHandler) + protected.Post("/umind/agentes", middlewares.SoloAdmin, controllers.CreateUmindAgenteHandler) + protected.Put("/umind/agentes/:id", middlewares.SoloAdmin, controllers.UpdateUmindAgenteHandler) + protected.Delete("/umind/agentes/:id", middlewares.SoloAdmin, controllers.DeleteUmindAgenteHandler) protected.Get("/umind/documentos", controllers.GetUmindDocumentosHandler) protected.Post("/umind/documentos", middlewares.SoloAdmin, controllers.CreateUmindDocumentoHandler) protected.Delete("/umind/documentos/:id", middlewares.SoloAdmin, controllers.DeleteUmindDocumentoHandler)