package controllers import ( "encoding/json" "fmt" "io" "log" "math" "net/http" "strconv" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/helpers" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) // ─── Panel: lista de configuraciones ───────────────────────────────────────── // SaasApiIndex renderiza la vista del panel de integraciones SaaS. func SaasApiIndex(c *fiber.Ctx) error { data := fiber.Map{ "user": c.Locals("user").(map[string]interface{}), "modules": c.Locals("userModules"), } if err := c.Render("saas_api", data, "layouts/main"); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return nil } // GetSaasApiConfigs devuelve la lista paginada en JSON. func GetSaasApiConfigs(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.GetAllSaasApiConfigs(limit, offset) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } saasOpts, _ := models.GetAllSaasProductosSelect() return c.JSON(fiber.Map{ "items": items, "saas": saasOpts, "total": total, "totalPages": int(math.Ceil(float64(total) / float64(limit))), "page": page, "limit": limit, }) } // CreateSaasApiConfig crea una nueva configuración de integración. func CreateSaasApiConfig(c *fiber.Ctx) error { type Req struct { SaasID uint `json:"saas_id"` Nombre string `json:"nombre"` Pasarela string `json:"pasarela"` EndpointURL string `json:"endpoint_url"` Metodo string `json:"metodo"` ApiKeyHeader string `json:"api_key_header"` ApiKeyValue string `json:"api_key_value"` PayloadTemplate string `json:"payload_template"` TimeoutSeg int `json:"timeout_seg"` Activo bool `json:"activo"` } var req Req if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) } if req.SaasID == 0 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "saas_id es requerido"}) } if strings.TrimSpace(req.Nombre) == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"}) } if strings.TrimSpace(req.EndpointURL) == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "endpoint_url es requerido"}) } metodo := strings.ToUpper(strings.TrimSpace(req.Metodo)) if metodo == "" { metodo = "POST" } pasarela := strings.ToLower(strings.TrimSpace(req.Pasarela)) if pasarela == "" { pasarela = "ambas" } timeout := req.TimeoutSeg if timeout <= 0 { timeout = 10 } item := models.SaasApiConfig{ SaasID: req.SaasID, Nombre: req.Nombre, Pasarela: pasarela, EndpointURL: req.EndpointURL, Metodo: metodo, ApiKeyHeader: req.ApiKeyHeader, ApiKeyValue: req.ApiKeyValue, PayloadTemplate: req.PayloadTemplate, TimeoutSeg: timeout, Activo: req.Activo, WebhookToken: helpers.RandomString(32), } if err := models.CreateSaasApiConfig(&item); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.Status(fiber.StatusCreated).JSON(item) } // UpdateSaasApiConfig actualiza una configuración existente. func UpdateSaasApiConfig(c *fiber.Ctx) error { idParam := c.Params("id") id64, err := strconv.ParseUint(idParam, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"}) } item, err := models.GetSaasApiConfigByID(uint(id64)) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no encontrado"}) } type Req struct { SaasID uint `json:"saas_id"` Nombre string `json:"nombre"` Pasarela string `json:"pasarela"` EndpointURL string `json:"endpoint_url"` Metodo string `json:"metodo"` ApiKeyHeader string `json:"api_key_header"` ApiKeyValue string `json:"api_key_value"` PayloadTemplate string `json:"payload_template"` TimeoutSeg int `json:"timeout_seg"` Activo bool `json:"activo"` } var req Req if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) } if req.SaasID != 0 { item.SaasID = req.SaasID } if strings.TrimSpace(req.Nombre) != "" { item.Nombre = req.Nombre } pasarela := strings.ToLower(strings.TrimSpace(req.Pasarela)) if pasarela != "" { item.Pasarela = pasarela } if strings.TrimSpace(req.EndpointURL) != "" { item.EndpointURL = req.EndpointURL } metodo := strings.ToUpper(strings.TrimSpace(req.Metodo)) if metodo != "" { item.Metodo = metodo } item.ApiKeyHeader = req.ApiKeyHeader // Solo actualizar api_key_value si se envió un valor (para no borrar el secreto con "") if req.ApiKeyValue != "" { item.ApiKeyValue = req.ApiKeyValue } item.PayloadTemplate = req.PayloadTemplate if req.TimeoutSeg > 0 { item.TimeoutSeg = req.TimeoutSeg } item.Activo = req.Activo if err := models.UpdateSaasApiConfig(item); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(item) } // DeleteSaasApiConfig elimina una configuración. func DeleteSaasApiConfig(c *fiber.Ctx) error { idParam := c.Params("id") id64, err := strconv.ParseUint(idParam, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"}) } if err := models.DeleteSaasApiConfig(uint(id64)); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"ok": true}) } // ProbaSaasApiConfig envía una petición de prueba al endpoint configurado. // Body JSON opcional: { "payload": "..." } — si no se envía usa el payload_template tal cual. func ProbaSaasApiConfig(c *fiber.Ctx) error { idParam := c.Params("id") id64, err := strconv.ParseUint(idParam, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"}) } cfg, err := models.GetSaasApiConfigByID(uint(id64)) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no encontrado"}) } type Req struct { Payload string `json:"payload"` } var req Req _ = c.BodyParser(&req) body := strings.TrimSpace(req.Payload) if body == "" { body = strings.TrimSpace(cfg.PayloadTemplate) } timeout := cfg.TimeoutSeg if timeout <= 0 { timeout = 10 } client := &http.Client{Timeout: time.Duration(timeout) * time.Second} var httpReq *http.Request metodo := strings.ToUpper(cfg.Metodo) if metodo == "" { metodo = "POST" } if body != "" && metodo != "GET" { httpReq, err = http.NewRequest(metodo, cfg.EndpointURL, strings.NewReader(body)) if err != nil { return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"error": "URL inválida: " + err.Error()}) } httpReq.Header.Set("Content-Type", "application/json") } else { httpReq, err = http.NewRequest(metodo, cfg.EndpointURL, nil) if err != nil { return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"error": "URL inválida: " + err.Error()}) } } if cfg.ApiKeyHeader != "" && cfg.ApiKeyValue != "" { httpReq.Header.Set(cfg.ApiKeyHeader, cfg.ApiKeyValue) } httpReq.Header.Set("User-Agent", "u-site-tester/1.0") start := time.Now() resp, err := client.Do(httpReq) latency := time.Since(start).Milliseconds() if err != nil { return c.JSON(fiber.Map{ "ok": false, "error": err.Error(), "latency_ms": latency, }) } defer resp.Body.Close() respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) // máx 64 KB return c.JSON(fiber.Map{ "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "http_status": resp.StatusCode, "body": string(respBytes), "latency_ms": latency, }) } // ─── Panel: logs de despacho ────────────────────────────────────────────────── // SaasDispatchLogIndex renderiza la vista de logs de despacho. func SaasDispatchLogIndex(c *fiber.Ctx) error { data := fiber.Map{ "user": c.Locals("user").(map[string]interface{}), "modules": c.Locals("userModules"), } if err := c.Render("saas_dispatch_logs", data, "layouts/main"); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return nil } // GetSaasDispatchLogs devuelve los logs de despacho paginados. func GetSaasDispatchLogs(c *fiber.Ctx) error { page, _ := strconv.Atoi(c.Query("page", "1")) if page < 1 { page = 1 } limit := 30 offset := (page - 1) * limit items, total, err := models.GetSaasDispatchLogs(limit, offset) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{ "items": items, "total": total, "totalPages": int(math.Ceil(float64(total) / float64(limit))), "page": page, "limit": limit, }) } // ─── Webhook entrante por token ─────────────────────────────────────────────── // SaasWebhookInHandler recibe llamadas entrantes del SaaS externo identificadas // por el WebhookToken único de la integración. No requiere autenticación. // Ruta pública: POST /webhooks/saas-in/:token func SaasWebhookInHandler(c *fiber.Ctx) error { token := strings.TrimSpace(c.Params("token")) if token == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "token requerido"}) } cfg, err := models.GetSaasApiConfigByWebhookToken(token) if err != nil { // Responder 200 para evitar reintentos agresivos de sistemas externos log.Printf("[SaasWebhookIn] Token desconocido: %s", token) return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": false, "msg": "token no reconocido"}) } // Capturar headers relevantes como JSON headersMap := map[string]string{} c.Request().Header.VisitAll(func(k, v []byte) { key := string(k) // Solo guardar headers informativos, excluir cookies/auth if key != "Cookie" && key != "Authorization" { headersMap[key] = string(v) } }) headersJSON, _ := json.Marshal(headersMap) rawBody := string(c.Body()) entry := models.SaasWebhookInLog{ SaasApiConfigID: cfg.ID, Token: token, Metodo: c.Method(), IP: c.IP(), Body: rawBody, Headers: string(headersJSON), } _ = models.SaveSaasWebhookInLog(&entry) log.Printf("[SaasWebhookIn] Integración '%s' (ID=%d) recibió callback desde IP=%s method=%s", cfg.Nombre, cfg.ID, c.IP(), c.Method()) return c.Status(fiber.StatusOK).JSON(fiber.Map{ "ok": true, "integracion": cfg.Nombre, "saas_api_id": cfg.ID, "message": fmt.Sprintf("Webhook recibido para integración: %s", cfg.Nombre), }) }