diff --git a/migrations/migrate.go b/migrations/migrate.go index 2374013..48fd64e 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -757,6 +757,16 @@ func MigratePortal() { } } +// MigrateLanding crea/actualiza la tabla de sesiones del Landing Generator. +func MigrateLanding() { + db := app.Http.Database.DB + if err := db.AutoMigrate(&models.LandingSession{}); err != nil { + log.Printf("[MIGRATE] Error en MigrateLanding: %v", err) + } else { + log.Println("[MIGRATE] Tabla landing_sessions OK") + } +} + // SeedTelegram agrega el submódulo de Telegram al módulo "Integraciones". Es idempotente. func SeedTelegram() { db := app.Http.Database.DB diff --git a/pkg/models/landing_session.go b/pkg/models/landing_session.go new file mode 100644 index 0000000..00e380d --- /dev/null +++ b/pkg/models/landing_session.go @@ -0,0 +1,101 @@ +package models + +import ( + "encoding/json" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// LandingSession representa una sesión de generación de landing page. +// Cada sesión tiene un token único que se usa como URL pública de preview. +type LandingSession struct { + gorm.Model + Token string `json:"token" gorm:"column:token;uniqueIndex;not null"` + UserName string `json:"user_name" gorm:"column:user_name"` + UserEmail string `json:"user_email" gorm:"column:user_email"` + UserPhone string `json:"user_phone" gorm:"column:user_phone"` + UserCompany string `json:"user_company" gorm:"column:user_company"` + UserJob string `json:"user_job" gorm:"column:user_job"` + UserWebsite string `json:"user_website" gorm:"column:user_website"` + UserAddress string `json:"user_address" gorm:"column:user_address"` + // Historial de mensajes almacenado como JSON + AnswersJSON string `json:"-" gorm:"column:answers_json;type:text"` + HTMLContent string `json:"html_content,omitempty" gorm:"column:html_content;type:text"` + // status: in_progress | generated | paid | expired + Status string `json:"status" gorm:"column:status;default:'in_progress'"` + BoldLinkID string `json:"bold_link_id" gorm:"column:bold_link_id"` + BoldPaid bool `json:"bold_paid" gorm:"column:bold_paid;default:false"` + BoldPaymentID string `json:"bold_payment_id" gorm:"column:bold_payment_id"` + PriceCOP int64 `json:"price_cop" gorm:"column:price_cop;default:49900"` + VcardIncluded bool `json:"vcard_included" gorm:"column:vcard_included;default:false"` +} + +func (LandingSession) TableName() string { return "landing_sessions" } + +// LandingMessage es un mensaje del chat (serializado en AnswersJSON). +type LandingMessage struct { + Role string `json:"role"` // "assistant" | "user" + Content string `json:"content"` +} + +// GetAnswers deserializa el historial de mensajes. +func (s *LandingSession) GetAnswers() []LandingMessage { + var msgs []LandingMessage + if s.AnswersJSON == "" { + return msgs + } + _ = json.Unmarshal([]byte(s.AnswersJSON), &msgs) + return msgs +} + +// SetAnswers serializa el historial de mensajes. +func (s *LandingSession) SetAnswers(msgs []LandingMessage) { + b, _ := json.Marshal(msgs) + s.AnswersJSON = string(b) +} + +// ─── CRUD ───────────────────────────────────────────────────────────────────── + +func CreateLandingSession(token, firstQuestion string) (*LandingSession, error) { + msgs := []LandingMessage{{Role: "assistant", Content: firstQuestion}} + b, _ := json.Marshal(msgs) + s := &LandingSession{ + Token: token, + Status: "in_progress", + AnswersJSON: string(b), + } + if err := app.Http.Database.DB.Create(s).Error; err != nil { + return nil, err + } + return s, nil +} + +func GetLandingSessionByToken(token string) (*LandingSession, error) { + var s LandingSession + if err := app.Http.Database.DB.Where("token = ?", token).First(&s).Error; err != nil { + return nil, err + } + return &s, nil +} + +func UpdateLandingSession(s *LandingSession) error { + return app.Http.Database.DB.Save(s).Error +} + +// GetAllLandingSessions devuelve sesiones paginadas para el panel admin. +func GetAllLandingSessions(limit, offset int, status string) ([]LandingSession, int64, error) { + var items []LandingSession + var total int64 + q := app.Http.Database.DB.Model(&LandingSession{}) + if status != "" { + q = q.Where("status = ?", status) + } + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := q.Order("created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} diff --git a/rest/controllers/api/landing_controller.go b/rest/controllers/api/landing_controller.go new file mode 100644 index 0000000..c780826 --- /dev/null +++ b/rest/controllers/api/landing_controller.go @@ -0,0 +1,359 @@ +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?"}, + {"cta", "¿Qué acción quieres que tome el visitante? (Ej: agendar cita, comprar, contactar, etc.)"}, + {"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", "¿Tienes sitio web o redes sociales que quieras enlazar? (Escríbelos o escribe \"no\" si no tienes)"}, + {"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, "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 "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, + "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, + }) +} diff --git a/rest/routes/publicas.go b/rest/routes/publicas.go index bc78ed7..5382cbd 100755 --- a/rest/routes/publicas.go +++ b/rest/routes/publicas.go @@ -62,4 +62,16 @@ func RutasPublicas(web fiber.Router) { }) // ─── Página de estado del sistema (Atlassian Statuspage) ──────────────── web.Get("/status", controllers.StatusPage) + + // ─── Landing Generator (acceso con X-Landing-Secret) ───────────────────── + // Rutas públicas (sin secret) para lectura de preview y polling + web.Get("/landing/preview/:token", apiControllers.LandingGetSession) + web.Get("/landing/poll/:token", apiControllers.LandingPollPayment) + // Rutas internas (requieren X-Landing-Secret del Next.js) + web.Post("/landing/session", apiControllers.LandingCreateSession) + web.Post("/landing/chat", apiControllers.LandingChat) + web.Post("/landing/generate", apiControllers.LandingGenerate) + web.Post("/landing/payment", apiControllers.LandingCreatePayment) + web.Get("/landing/answers/:token", apiControllers.LandingGetAnswers) + web.Get("/landing/download/:token", apiControllers.LandingDownload) } diff --git a/rest/routes/user.go b/rest/routes/user.go index 51fcfde..9bad609 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -235,6 +235,9 @@ func UserRoutes(app fiber.Router) { protected.Post("/pasarelas/bold/crear-link", apiControllers.BoldCreatePaymentLink) protected.Get("/pasarelas/bold/link/:linkID", apiControllers.BoldGetLinkStatus) + // ─── Landing Generator (admin) ──────────────────────────────────────────── + protected.Get("/landing-pages", middlewares.MenuMiddleware, apiControllers.LandingAdminList) + // ─── Productos SaaS ────────────────────────────────────────────────────── protected.Get("/saas", middlewares.MenuMiddleware, controllers.SaasIndex) protected.Get("/loadsaas", controllers.GetSaasProductos)