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()}) } 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 := 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 }