Files
soft_usite/rest/controllers/api/landing_controller.go
T
Lizandro GuarnizoandClaude Sonnet 5 96cd24f17d feat(ai-config): cifra la API key en reposo y la acota por tenant
Prepara el terreno para que el cliente cargue su propia cuenta de IA sin
que su clave quede en texto plano ni que el selector le muestre las de
los demás.

- ClaveEnClaro() descifra con fallback a texto plano: las filas viejas se
  leen igual y quedan cifradas al primer guardado, sin script ni downtime.
  utils.Decrypt hace panic (no devuelve error) con entrada que no es un
  ciphertext válido, así que el fallback va sobre recover — eso mismo es
  el mecanismo de detección de "todavía está en claro".
- Migrados TODOS los lectores: uMind (chat y embeddings), bot de Telegram,
  Whisper, Query Runner, streaming de IA y Landing Generator. Un lector
  sin migrar mandaría el ciphertext como API key.
- AiConfig gana TenantID (null = global del staff) y
  GetAiConfigSelectPorTenants para acotar el selector.
- Test de los 4 casos del fallback, incluido hex válido que no descifra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:36:47 -05:00

387 lines
14 KiB
Go

package controllers
import (
"os"
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/helpers"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// landingSecret verifica que el header X-Landing-Secret sea correcto.
// Si LANDING_API_SECRET no está configurado, se rechaza por seguridad.
func landingSecret(c *fiber.Ctx) bool {
secret := os.Getenv("LANDING_API_SECRET")
if secret == "" {
return false
}
return c.Get("X-Landing-Secret") == secret
}
// ─── POST /landing/session ────────────────────────────────────────────────────
// LandingCreateSession crea una nueva sesión de chat para el generador de landing.
func LandingCreateSession(c *fiber.Ctx) error {
if !landingSecret(c) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no autorizado"})
}
const firstQuestion = "¡Hola! Soy tu asistente de landing pages. ¿Cuál es tu nombre o el nombre de tu negocio?"
// Token simple: alfanumérico, 16 chars
token := helpers.RandomString(16)
session, err := models.CreateLandingSession(token, firstQuestion)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"id": session.ID, "token": session.Token})
}
// ─── POST /landing/chat ───────────────────────────────────────────────────────
var landingQuestions = []struct {
Key string
Question string
}{
{"user_name", "¡Hola! Soy tu asistente de landing pages. ¿Cuál es tu nombre o el nombre de tu negocio?"},
{"business_type", "¿A qué se dedica tu negocio? Descríbelo brevemente."},
{"target", "¿A quién va dirigida tu landing page? ¿Cuál es tu cliente ideal?"},
{"offer", "¿Qué producto o servicio principal quieres destacar?"},
{"value_prop", "¿Cuál es tu propuesta de valor? ¿Por qué deberían elegirte a ti y no a la competencia?"},
{"colors", "¿Tienes colores de marca? Descríbelos o dime el estilo visual que prefieres. (Ej: moderno, minimalista, corporativo, colorido)"},
{"user_phone", "¿Cuál es tu número de teléfono o WhatsApp de contacto?"},
{"user_email", "¿Cuál es tu correo electrónico de contacto?"},
{"user_website", "¿Cuál es tu sitio web o portafolio?"},
{"logo_url", "¿Tienes un logo? Comparte la URL de tu logo (o escribe \"no\" si no tienes)"},
{"social_media", "¿Qué redes sociales tienes? Comparte tus perfiles de Instagram, LinkedIn, YouTube, TikTok, etc. (Ej: Instagram: @tunombre, LinkedIn: /in/tunombre)"},
{"user_address", "¿Tienes una dirección física o ciudad donde operas?"},
{"extras", "¿Hay algo más que quieras incluir? Testimonios, precios, galería, FAQ... (O escribe \"listo\" para generar ya tu landing)"},
}
// LandingChat recibe el mensaje del usuario y devuelve la siguiente pregunta.
func LandingChat(c *fiber.Ctx) error {
if !landingSecret(c) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no autorizado"})
}
type body struct {
Token string `json:"token"`
Message string `json:"message"`
}
var req body
if err := c.BodyParser(&req); err != nil || req.Token == "" || req.Message == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "token y message son requeridos"})
}
session, err := models.GetLandingSessionByToken(req.Token)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "sesión no encontrada"})
}
if session.Status != "in_progress" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sesión ya finalizada"})
}
msgs := session.GetAnswers()
userAnswersCount := 0
for _, m := range msgs {
if m.Role == "user" {
userAnswersCount++
}
}
msgs = append(msgs, models.LandingMessage{Role: "user", Content: req.Message})
// Guardar campo estructurado si corresponde
contactFields := map[string]bool{"user_name": true, "user_phone": true, "user_email": true, "user_website": true, "logo_url": true, "social_media": true, "user_address": true}
if userAnswersCount < len(landingQuestions) {
key := landingQuestions[userAnswersCount].Key
if contactFields[key] {
switch key {
case "user_name":
session.UserName = req.Message
case "user_phone":
session.UserPhone = req.Message
case "user_email":
session.UserEmail = req.Message
case "user_website":
session.UserWebsite = req.Message
case "logo_url":
session.LogoUrl = req.Message
case "social_media":
session.SocialMedia = req.Message
case "user_address":
session.UserAddress = req.Message
}
}
}
nextIndex := userAnswersCount + 1
var nextMessage string
readyToGenerate := false
if nextIndex < len(landingQuestions) {
nextMessage = landingQuestions[nextIndex].Question
} else {
readyToGenerate = true
nextMessage = "¡Perfecto! Ya tengo toda la información. ¿Listo para ver tu landing page generada por IA? 🚀"
}
msgs = append(msgs, models.LandingMessage{Role: "assistant", Content: nextMessage})
session.SetAnswers(msgs)
if err := models.UpdateLandingSession(session); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"message": nextMessage,
"readyToGenerate": readyToGenerate,
"questionIndex": nextIndex,
"totalQuestions": len(landingQuestions),
})
}
// ─── GET /landing/preview/:token ─────────────────────────────────────────────
// LandingGetSession devuelve los datos públicos de una sesión (sin HTML si no está pagada).
func LandingGetSession(c *fiber.Ctx) error {
token := c.Params("token")
session, err := models.GetLandingSessionByToken(token)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no encontrado"})
}
resp := fiber.Map{
"id": session.ID,
"token": session.Token,
"status": session.Status,
"bold_paid": session.BoldPaid,
"price_cop": session.PriceCOP,
"user_name": session.UserName,
"user_email": session.UserEmail,
"user_phone": session.UserPhone,
"user_company": session.UserCompany,
"user_job": session.UserJob,
"user_website": session.UserWebsite,
"user_address": session.UserAddress,
"social_media": session.SocialMedia,
"logo_url": session.LogoUrl,
"vcard_included": session.VcardIncluded,
}
// Solo exponer el HTML si la sesión está generada (preview con marca de agua siempre)
if session.HTMLContent != "" {
resp["html_content"] = session.HTMLContent
}
return c.JSON(resp)
}
// LandingGetAnswers devuelve el historial completo de mensajes (requiere secret).
func LandingGetAnswers(c *fiber.Ctx) error {
if !landingSecret(c) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no autorizado"})
}
token := c.Params("token")
session, err := models.GetLandingSessionByToken(token)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no encontrado"})
}
return c.JSON(fiber.Map{
"answers": session.GetAnswers(),
})
}
// ─── POST /landing/generate ───────────────────────────────────────────────────
// LandingGenerate delega la generación del HTML a un servicio externo (Qwen).
// Recibe el HTML ya generado desde el Next.js y lo persiste.
func LandingGenerate(c *fiber.Ctx) error {
if !landingSecret(c) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no autorizado"})
}
type body struct {
Token string `json:"token"`
HTMLContent string `json:"html_content"`
VcardIncluded bool `json:"vcard_included"`
}
var req body
if err := c.BodyParser(&req); err != nil || req.Token == "" || req.HTMLContent == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "token y html_content requeridos"})
}
session, err := models.GetLandingSessionByToken(req.Token)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "sesión no encontrada"})
}
session.HTMLContent = req.HTMLContent
session.Status = "generated"
session.VcardIncluded = req.VcardIncluded
if err := models.UpdateLandingSession(session); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "token": session.Token})
}
// ─── POST /landing/payment ────────────────────────────────────────────────────
// LandingCreatePayment genera el link de pago Bold para la sesión.
func LandingCreatePayment(c *fiber.Ctx) error {
if !landingSecret(c) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no autorizado"})
}
type body struct {
Token string `json:"token"`
CallbackURL string `json:"callback_url"`
}
var req body
if err := c.BodyParser(&req); err != nil || req.Token == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "token requerido"})
}
session, err := models.GetLandingSessionByToken(req.Token)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "sesión no encontrada"})
}
if session.BoldPaid {
return c.JSON(fiber.Map{"paid": true, "token": session.Token})
}
if session.BoldLinkID != "" {
boldURL := "https://checkout.bold.co/payment/" + session.BoldLinkID
return c.JSON(fiber.Map{"url": boldURL, "link_id": session.BoldLinkID})
}
cfg, err := models.GetBoldConfig()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "sin configuración Bold activa"})
}
reference := "landing-" + session.Token
callbackURL := req.CallbackURL
result, err := services.CreateBoldPaymentLink(cfg, services.BoldPaymentLinkRequest{
AmountType: "CLOSE",
Amount: services.BoldAmountField{Currency: "COP", TotalAmount: session.PriceCOP},
Description: "Landing Page Profesional",
Reference: reference,
PayerEmail: session.UserEmail,
CallbackURL: callbackURL,
})
if err != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
}
session.BoldLinkID = result.Payload.PaymentLink
_ = models.UpdateLandingSession(session)
return c.JSON(fiber.Map{"url": result.Payload.URL, "link_id": result.Payload.PaymentLink})
}
// ─── GET /landing/poll/:token ─────────────────────────────────────────────────
// LandingPollPayment verifica el estado del pago (polling desde el cliente).
func LandingPollPayment(c *fiber.Ctx) error {
token := c.Params("token")
session, err := models.GetLandingSessionByToken(token)
if err != nil {
return c.JSON(fiber.Map{"paid": false})
}
if session.BoldPaid {
return c.JSON(fiber.Map{"paid": true})
}
// Consultar la API de Bold si hay un link_id
if session.BoldLinkID != "" {
cfg, err := models.GetBoldConfig()
if err == nil {
paid, paymentID, _, checkErr := services.CheckBoldLinkPaid(cfg, session.BoldLinkID)
if checkErr == nil && paid {
session.BoldPaid = true
session.BoldPaymentID = paymentID
session.Status = "paid"
_ = models.UpdateLandingSession(session)
return c.JSON(fiber.Map{"paid": true})
}
}
}
return c.JSON(fiber.Map{"paid": false})
}
// ─── GET /landing/download/:token ────────────────────────────────────────────
// LandingDownload entrega el HTML (o ZIP) solo si la sesión está pagada.
func LandingDownload(c *fiber.Ctx) error {
if !landingSecret(c) {
return c.Status(fiber.StatusUnauthorized).SendString("no autorizado")
}
token := c.Params("token")
session, err := models.GetLandingSessionByToken(token)
if err != nil {
return c.Status(fiber.StatusNotFound).SendString("no encontrado")
}
if !session.BoldPaid {
return c.Status(fiber.StatusPaymentRequired).SendString("pago requerido")
}
if session.HTMLContent == "" {
return c.Status(fiber.StatusNotFound).SendString("HTML no generado")
}
c.Set("Content-Type", "text/html; charset=utf-8")
c.Set("Content-Disposition", "attachment; filename=\"landing.html\"")
return c.SendString(session.HTMLContent)
}
// ─── GET /app/landing-pages (admin) ──────────────────────────────────────────
// LandingAdminList lista todas las sesiones para el panel de administración.
func LandingAdminList(c *fiber.Ctx) error {
page, _ := strconv.Atoi(c.Query("page", "1"))
perPage := 20
offset := (page - 1) * perPage
statusFilter := c.Query("status", "")
sessions, total, err := models.GetAllLandingSessions(perPage, offset, statusFilter)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"data": sessions,
"total": total,
"page": page,
})
}
// ─── GET /landing/ai-config ───────────────────────────────────────────────────
// LandingGetAiConfig devuelve la configuración de IA activa para el Landing Generator.
// Protegido por X-Landing-Secret.
func LandingGetAiConfig(c *fiber.Ctx) error {
if !landingSecret(c) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no autorizado"})
}
config, err := models.GetAiConfigForService("landing")
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no hay configuración de IA activa"})
}
return c.JSON(fiber.Map{
"provider": config.Provider,
"api_key": config.ClaveEnClaro(),
"base_url": config.BaseURL,
"model_name": config.ModelName,
})
}