From 6bf4b32a18ec4667626cf7bc93124ce77b3e2318 Mon Sep 17 00:00:00 2001
From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com>
Date: Wed, 6 May 2026 14:58:55 -0500
Subject: [PATCH] documentacion
---
migrations/migrate.go | 4 +
pkg/models/doc_categoria.go | 67 ++++
pkg/models/doc_pagina.go | 166 +++++++++
pkg/models/saas_producto.go | 82 +++++
resources/views/docs/categorias.html | 185 ++++++++++
resources/views/docs/paginas.html | 364 +++++++++++++++++++
resources/views/docs/private-index.html | 47 +++
resources/views/docs/private-page.html | 94 +++++
resources/views/docs/public-index.html | 56 +++
resources/views/docs/public-page.html | 108 ++++++
resources/views/saas.html | 224 ++++++++++++
rest/controllers/doc_categoria_controller.go | 112 ++++++
rest/controllers/doc_pagina_controller.go | 359 ++++++++++++++++++
rest/controllers/saas_controller.go | 128 +++++++
rest/routes/publicas.go | 4 +-
rest/routes/user.go | 26 ++
16 files changed, 2025 insertions(+), 1 deletion(-)
create mode 100644 pkg/models/doc_categoria.go
create mode 100644 pkg/models/doc_pagina.go
create mode 100644 pkg/models/saas_producto.go
create mode 100644 resources/views/docs/categorias.html
create mode 100644 resources/views/docs/paginas.html
create mode 100644 resources/views/docs/private-index.html
create mode 100644 resources/views/docs/private-page.html
create mode 100644 resources/views/docs/public-index.html
create mode 100644 resources/views/docs/public-page.html
create mode 100644 resources/views/saas.html
create mode 100644 rest/controllers/doc_categoria_controller.go
create mode 100644 rest/controllers/doc_pagina_controller.go
create mode 100644 rest/controllers/saas_controller.go
diff --git a/migrations/migrate.go b/migrations/migrate.go
index f141559..ea88b43 100755
--- a/migrations/migrate.go
+++ b/migrations/migrate.go
@@ -60,6 +60,10 @@ func Migrate() {
&models.BoldWebhookLog{},
&models.BoldCallbackLog{},
&models.DlocalPaymentLog{},
+ // Documentación por SaaS
+ &models.SaasProducto{},
+ &models.DocCategoria{},
+ &models.DocPagina{},
); err != nil {
log.Fatalf("Error during main migration: %v", err)
}
diff --git a/pkg/models/doc_categoria.go b/pkg/models/doc_categoria.go
new file mode 100644
index 0000000..d3038b5
--- /dev/null
+++ b/pkg/models/doc_categoria.go
@@ -0,0 +1,67 @@
+package models
+
+import (
+ "github.com/sujit-baniya/fiber-boilerplate/app"
+ "gorm.io/gorm"
+)
+
+// DocCategoria es una categoría GLOBAL que puede aplicarse a la documentación de cualquier SaaS.
+// Ejemplos: "Primeros Pasos", "API Reference", "Configuración", "Facturación".
+type DocCategoria struct {
+ gorm.Model
+ Nombre string `json:"nombre" gorm:"column:nombre;not null"`
+ Slug string `json:"slug" gorm:"column:slug;uniqueIndex;not null"`
+ Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
+ Orden int `json:"orden" gorm:"column:orden;default:0"`
+}
+
+func (DocCategoria) TableName() string { return "doc_categorias" }
+
+func GetAllDocCategorias(limit, offset int, search string) ([]DocCategoria, int64, error) {
+ var items []DocCategoria
+ var total int64
+ db := app.Http.Database.DB.Model(&DocCategoria{})
+ if search != "" {
+ db = db.Where("nombre ILIKE ? OR descripcion ILIKE ?", "%"+search+"%", "%"+search+"%")
+ }
+ if err := db.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ if err := db.Order("orden ASC, nombre ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
+ return nil, 0, err
+ }
+ return items, total, nil
+}
+
+func GetAllDocCategoriasSelect() ([]DocCategoria, error) {
+ var items []DocCategoria
+ if err := app.Http.Database.DB.Order("orden ASC, nombre ASC").Find(&items).Error; err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+func GetDocCategoriaByID(id uint) (*DocCategoria, error) {
+ var item DocCategoria
+ if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
+ return nil, err
+ }
+ return &item, nil
+}
+
+func CreateDocCategoria(c *DocCategoria) error {
+ return app.Http.Database.DB.Create(c).Error
+}
+
+func UpdateDocCategoria(c *DocCategoria) error {
+ return app.Http.Database.DB.Model(&DocCategoria{}).Where("id = ?", c.ID).Updates(map[string]interface{}{
+ "nombre": c.Nombre,
+ "slug": c.Slug,
+ "descripcion": c.Descripcion,
+ "orden": c.Orden,
+ }).Error
+}
+
+func DeleteDocCategoria(id uint) error {
+ return app.Http.Database.DB.Delete(&DocCategoria{}, id).Error
+}
diff --git a/pkg/models/doc_pagina.go b/pkg/models/doc_pagina.go
new file mode 100644
index 0000000..91d4c49
--- /dev/null
+++ b/pkg/models/doc_pagina.go
@@ -0,0 +1,166 @@
+package models
+
+import (
+ "github.com/sujit-baniya/fiber-boilerplate/app"
+ "gorm.io/gorm"
+)
+
+// DocPagina es el contenido real de documentación.
+// Visibilidad "public" → accesible sin login.
+// Visibilidad "private" → requiere login Y tener al menos uno de los roles asignados.
+// TipoContenido "markdown" | "html" determina cómo se renderiza el campo Contenido.
+type DocPagina struct {
+ gorm.Model
+ SaasID uint `json:"saas_id" gorm:"column:saas_id;not null"`
+ Saas SaasProducto `json:"saas" gorm:"foreignKey:SaasID"`
+ CategoriaID uint `json:"categoria_id" gorm:"column:categoria_id;not null"`
+ Categoria DocCategoria `json:"categoria" gorm:"foreignKey:CategoriaID"`
+ Titulo string `json:"titulo" gorm:"column:titulo;not null"`
+ Slug string `json:"slug" gorm:"column:slug;not null;uniqueIndex:idx_saas_slug"`
+ Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
+ TipoContenido string `json:"tipo_contenido" gorm:"column:tipo_contenido;default:'markdown'"` // markdown | html
+ Visibilidad string `json:"visibilidad" gorm:"column:visibilidad;default:'public'"` // public | private
+ Orden int `json:"orden" gorm:"column:orden;default:0"`
+ Publicado bool `json:"publicado" gorm:"column:publicado;default:false"`
+ CreadoPor uint `json:"creado_por" gorm:"column:creado_por"`
+ // Roles que pueden ver esta página cuando visibilidad = "private"
+ Roles []Roles `json:"roles" gorm:"many2many:doc_pagina_roles"`
+}
+
+func (DocPagina) TableName() string { return "doc_paginas" }
+
+// ─── Queries ──────────────────────────────────────────────────────────────────
+
+func GetAllDocPaginas(limit, offset int, search string, saasID, categoriaID uint) ([]DocPagina, int64, error) {
+ var items []DocPagina
+ var total int64
+ db := app.Http.Database.DB.Model(&DocPagina{}).
+ Preload("Saas").Preload("Categoria").Preload("Roles")
+ if search != "" {
+ db = db.Where("titulo ILIKE ?", "%"+search+"%")
+ }
+ if saasID > 0 {
+ db = db.Where("saas_id = ?", saasID)
+ }
+ if categoriaID > 0 {
+ db = db.Where("categoria_id = ?", categoriaID)
+ }
+ if err := db.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ if err := db.Order("saas_id ASC, orden ASC, created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
+ return nil, 0, err
+ }
+ return items, total, nil
+}
+
+// GetDocPaginasPublicas devuelve páginas publicadas y públicas de un SaaS, agrupables por categoría.
+func GetDocPaginasPublicas(saasID uint) ([]DocPagina, error) {
+ var items []DocPagina
+ if err := app.Http.Database.DB.
+ Preload("Categoria").
+ Where("saas_id = ? AND visibilidad = 'public' AND publicado = true", saasID).
+ Order("orden ASC, titulo ASC").
+ Find(&items).Error; err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+// GetDocPaginasParaRoles devuelve páginas publicadas accesibles por los roles dados (incluye public + private con rol).
+func GetDocPaginasParaRoles(saasID uint, rolNames []string) ([]DocPagina, error) {
+ var items []DocPagina
+ db := app.Http.Database.DB.Preload("Categoria").Preload("Roles")
+
+ // Páginas públicas del SaaS
+ if err := db.Where("saas_id = ? AND publicado = true AND visibilidad = 'public'", saasID).
+ Order("orden ASC, titulo ASC").Find(&items).Error; err != nil {
+ return nil, err
+ }
+
+ // Páginas privadas accesibles por los roles del usuario
+ if len(rolNames) > 0 {
+ var privadas []DocPagina
+ if err := app.Http.Database.DB.Preload("Categoria").Preload("Roles").
+ Joins("JOIN doc_pagina_roles dpr ON dpr.doc_pagina_id = doc_paginas.id").
+ Joins("JOIN roles r ON r.id = dpr.roles_id").
+ Where("doc_paginas.saas_id = ? AND doc_paginas.publicado = true AND doc_paginas.visibilidad = 'private' AND r.name IN ?", saasID, rolNames).
+ Group("doc_paginas.id").
+ Order("doc_paginas.orden ASC, doc_paginas.titulo ASC").
+ Find(&privadas).Error; err != nil {
+ return nil, err
+ }
+ items = append(items, privadas...)
+ }
+ return items, nil
+}
+
+func GetDocPaginaByID(id uint) (*DocPagina, error) {
+ var item DocPagina
+ if err := app.Http.Database.DB.Preload("Saas").Preload("Categoria").Preload("Roles").First(&item, id).Error; err != nil {
+ return nil, err
+ }
+ return &item, nil
+}
+
+func GetDocPaginaBySlug(saasID uint, slug string) (*DocPagina, error) {
+ var item DocPagina
+ if err := app.Http.Database.DB.Preload("Saas").Preload("Categoria").Preload("Roles").
+ Where("saas_id = ? AND slug = ?", saasID, slug).
+ First(&item).Error; err != nil {
+ return nil, err
+ }
+ return &item, nil
+}
+
+func CreateDocPagina(p *DocPagina, rolIDs []uint) error {
+ db := app.Http.Database.DB
+ if err := db.Omit("Roles.*").Create(p).Error; err != nil {
+ return err
+ }
+ return syncDocPaginaRoles(p.ID, rolIDs)
+}
+
+func UpdateDocPagina(p *DocPagina, rolIDs []uint) error {
+ db := app.Http.Database.DB
+ if err := db.Model(&DocPagina{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
+ "saas_id": p.SaasID,
+ "categoria_id": p.CategoriaID,
+ "titulo": p.Titulo,
+ "slug": p.Slug,
+ "contenido": p.Contenido,
+ "tipo_contenido": p.TipoContenido,
+ "visibilidad": p.Visibilidad,
+ "orden": p.Orden,
+ "publicado": p.Publicado,
+ }).Error; err != nil {
+ return err
+ }
+ return syncDocPaginaRoles(p.ID, rolIDs)
+}
+
+func DeleteDocPagina(id uint) error {
+ db := app.Http.Database.DB
+ // Limpiar la tabla join antes de borrar el registro
+ if err := db.Exec("DELETE FROM doc_pagina_roles WHERE doc_pagina_id = ?", id).Error; err != nil {
+ return err
+ }
+ return db.Delete(&DocPagina{}, id).Error
+}
+
+// syncDocPaginaRoles sincroniza los roles de una página usando SQL directo.
+func syncDocPaginaRoles(paginaID uint, rolIDs []uint) error {
+ db := app.Http.Database.DB
+ if err := db.Exec("DELETE FROM doc_pagina_roles WHERE doc_pagina_id = ?", paginaID).Error; err != nil {
+ return err
+ }
+ for _, rid := range rolIDs {
+ if err := db.Exec(
+ "INSERT INTO doc_pagina_roles (doc_pagina_id, roles_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
+ paginaID, rid,
+ ).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/pkg/models/saas_producto.go b/pkg/models/saas_producto.go
new file mode 100644
index 0000000..ee740a3
--- /dev/null
+++ b/pkg/models/saas_producto.go
@@ -0,0 +1,82 @@
+package models
+
+import (
+ "github.com/sujit-baniya/fiber-boilerplate/app"
+ "gorm.io/gorm"
+)
+
+// SaasProducto representa un producto SaaS al que se le puede asociar documentación.
+// servicio_id es opcional: un SaaS puede estar relacionado con un servicio existente o ser independiente.
+type SaasProducto struct {
+ gorm.Model
+ Nombre string `json:"nombre" gorm:"column:nombre;not null"`
+ Slug string `json:"slug" gorm:"column:slug;uniqueIndex;not null"`
+ Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
+ LogoURL string `json:"logo_url" gorm:"column:logo_url"`
+ ServicioID *uint `json:"servicio_id" gorm:"column:servicio_id"` // nullable FK
+ Servicio *Servicio `json:"servicio" gorm:"foreignKey:ServicioID"`
+ Activo bool `json:"activo" gorm:"column:activo;default:true"`
+ Orden int `json:"orden" gorm:"column:orden;default:0"`
+}
+
+func (SaasProducto) TableName() string { return "saas_productos" }
+
+func GetAllSaasProductos(limit, offset int, search string) ([]SaasProducto, int64, error) {
+ var items []SaasProducto
+ var total int64
+ db := app.Http.Database.DB.Model(&SaasProducto{}).Preload("Servicio")
+ if search != "" {
+ db = db.Where("nombre ILIKE ? OR descripcion ILIKE ?", "%"+search+"%", "%"+search+"%")
+ }
+ if err := db.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ if err := db.Order("orden ASC, created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
+ return nil, 0, err
+ }
+ return items, total, nil
+}
+
+func GetAllSaasProductosSelect() ([]SaasProducto, error) {
+ var items []SaasProducto
+ if err := app.Http.Database.DB.Where("activo = ?", true).Order("orden ASC, nombre ASC").Find(&items).Error; err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+func GetSaasProductoByID(id uint) (*SaasProducto, error) {
+ var item SaasProducto
+ if err := app.Http.Database.DB.Preload("Servicio").First(&item, id).Error; err != nil {
+ return nil, err
+ }
+ return &item, nil
+}
+
+func GetSaasProductoBySlug(slug string) (*SaasProducto, error) {
+ var item SaasProducto
+ if err := app.Http.Database.DB.Where("slug = ?", slug).First(&item).Error; err != nil {
+ return nil, err
+ }
+ return &item, nil
+}
+
+func CreateSaasProducto(s *SaasProducto) error {
+ return app.Http.Database.DB.Create(s).Error
+}
+
+func UpdateSaasProducto(s *SaasProducto) error {
+ return app.Http.Database.DB.Model(&SaasProducto{}).Where("id = ?", s.ID).Updates(map[string]interface{}{
+ "nombre": s.Nombre,
+ "slug": s.Slug,
+ "descripcion": s.Descripcion,
+ "logo_url": s.LogoURL,
+ "servicio_id": s.ServicioID,
+ "activo": s.Activo,
+ "orden": s.Orden,
+ }).Error
+}
+
+func DeleteSaasProducto(id uint) error {
+ return app.Http.Database.DB.Delete(&SaasProducto{}, id).Error
+}
diff --git a/resources/views/docs/categorias.html b/resources/views/docs/categorias.html
new file mode 100644
index 0000000..d7022f8
--- /dev/null
+++ b/resources/views/docs/categorias.html
@@ -0,0 +1,185 @@
+
+
+
+
Categorías de Documentación
+
Categorías globales que aplican a todos los productos SaaS. Ejemplos: "Primeros Pasos", "API Reference", "Facturación".
+
+
+
+
+
+
+
+ | Nombre |
+ Slug |
+ Descripción |
+ Orden |
+ |
+
+
+
+
+ | Sin categorías registradas |
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Confirmar eliminación
+
¿Eliminar esta categoría? Las páginas que la usen quedarán sin categoría.
+
+
+
+
+
+
+
+
+
diff --git a/resources/views/docs/paginas.html b/resources/views/docs/paginas.html
new file mode 100644
index 0000000..c75da72
--- /dev/null
+++ b/resources/views/docs/paginas.html
@@ -0,0 +1,364 @@
+
+
+
+
+
+
+
+
+
+
Páginas de Documentación
+
Crea y gestiona el contenido de documentación para cada producto SaaS.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Título |
+ SaaS |
+ Categoría |
+ Tipo |
+ Visibilidad |
+ Estado |
+ |
+
+
+
+
+ | Sin páginas registradas |
+
+
+
+ |
+
+
+ |
+ |
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Confirmar eliminación
+
¿Eliminar esta página de documentación? Esta acción no se puede deshacer.
+
+
+
+
+
+
+
+
+
diff --git a/resources/views/docs/private-index.html b/resources/views/docs/private-index.html
new file mode 100644
index 0000000..3e161ed
--- /dev/null
+++ b/resources/views/docs/private-index.html
@@ -0,0 +1,47 @@
+
+
+
+
+ {{if .saas.LogoURL}}
+

+ {{end}}
+
+
{{.saas.Nombre}}
+
{{.saas.Descripcion}}
+
+
+
+
+ {{if not .grupos}}
+
+
+
Sin páginas disponibles para tu perfil en este producto.
+
+ {{else}}
+
+ {{range .grupos}}
+
+
+
{{.Categoria.Nombre}}
+
+
+
+ {{end}}
+
+ {{end}}
+
+
diff --git a/resources/views/docs/private-page.html b/resources/views/docs/private-page.html
new file mode 100644
index 0000000..af53e4f
--- /dev/null
+++ b/resources/views/docs/private-page.html
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+ {{.saas.Nombre}}
+
+
/
+
{{.pagina.Categoria.Nombre}}
+
/
+
{{.pagina.Titulo}}
+ {{if eq .pagina.Visibilidad "private"}}
+
🔒 Acceso privado
+ {{end}}
+
+
+
+
+
+
+
+
+
+ {{.pagina.Categoria.Nombre}}
+
{{.pagina.Titulo}}
+
+
+ {{if eq .pagina.TipoContenido "markdown"}}
+
+ {{else}}
+ {{.pagina.Contenido}}
+ {{end}}
+
+
+
+
+
+
+
diff --git a/resources/views/docs/public-index.html b/resources/views/docs/public-index.html
new file mode 100644
index 0000000..5063cf1
--- /dev/null
+++ b/resources/views/docs/public-index.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+ {{if .saas.LogoURL}}
+

+ {{end}}
+
+
{{.saas.Nombre}}
+
{{.saas.Descripcion}}
+
+
+
+
+
+ {{if not .grupos}}
+
+
+
Sin documentación publicada
+
Aún no hay páginas disponibles para este producto.
+
+ {{else}}
+
+ {{range .grupos}}
+
+
+
{{.Categoria.Nombre}}
+ {{if .Categoria.Descripcion}}
+
{{.Categoria.Descripcion}}
+ {{end}}
+
+
+
+ {{end}}
+
+ {{end}}
+
+
diff --git a/resources/views/docs/public-page.html b/resources/views/docs/public-page.html
new file mode 100644
index 0000000..6aea47a
--- /dev/null
+++ b/resources/views/docs/public-page.html
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{.pagina.Categoria.Nombre}}
+
+
{{.pagina.Titulo}}
+
+
+
+ {{if eq .pagina.TipoContenido "markdown"}}
+
+ {{else}}
+
+ {{.pagina.Contenido}}
+
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/views/saas.html b/resources/views/saas.html
new file mode 100644
index 0000000..a6f4d0a
--- /dev/null
+++ b/resources/views/saas.html
@@ -0,0 +1,224 @@
+
+
+
+
Productos SaaS
+
Gestiona los productos SaaS a los que se asociará documentación.
+
+
+
+
+
+
+
+
+ | Nombre |
+ Slug |
+ Servicio vinculado |
+ Orden |
+ Estado |
+ |
+
+
+
+
+ | Sin registros |
+
+
+
+ |
+ |
+ |
+ |
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Confirmar eliminación
+
¿Eliminar este producto SaaS? También se eliminará su documentación asociada.
+
+
+
+
+
+
+
+
+
diff --git a/rest/controllers/doc_categoria_controller.go b/rest/controllers/doc_categoria_controller.go
new file mode 100644
index 0000000..863d5e0
--- /dev/null
+++ b/rest/controllers/doc_categoria_controller.go
@@ -0,0 +1,112 @@
+package controllers
+
+import (
+ "math"
+ "strconv"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
+)
+
+// DocCategoriasIndex renderiza el panel de gestión de categorías de documentación.
+func DocCategoriasIndex(c *fiber.Ctx) error {
+ data := fiber.Map{
+ "user": c.Locals("user").(map[string]interface{}),
+ "modules": c.Locals("userModules"),
+ }
+ if err := c.Render("docs/categorias", data, "layouts/main"); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return nil
+}
+
+// GetDocCategorias devuelve la lista paginada de categorías en JSON.
+func GetDocCategorias(c *fiber.Ctx) error {
+ page, _ := strconv.Atoi(c.Query("page", "1"))
+ if page < 1 {
+ page = 1
+ }
+ search := c.Query("search", "")
+ limit := 10
+ offset := (page - 1) * limit
+
+ items, total, err := models.GetAllDocCategorias(limit, offset, search)
+ 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,
+ })
+}
+
+// CreateDocCategoria crea una nueva categoría global.
+func CreateDocCategoria(c *fiber.Ctx) error {
+ type Req struct {
+ Nombre string `json:"nombre"`
+ Slug string `json:"slug"`
+ Descripcion string `json:"descripcion"`
+ Orden int `json:"orden"`
+ }
+ var req Req
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Datos inválidos"})
+ }
+ if req.Nombre == "" || req.Slug == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Nombre y slug son obligatorios"})
+ }
+ item := &models.DocCategoria{
+ Nombre: req.Nombre,
+ Slug: req.Slug,
+ Descripcion: req.Descripcion,
+ Orden: req.Orden,
+ }
+ if err := models.CreateDocCategoria(item); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"message": "Categoría creada", "item": item})
+}
+
+// UpdateDocCategoria actualiza una categoría existente.
+func UpdateDocCategoria(c *fiber.Ctx) error {
+ id, err := strconv.ParseUint(c.Params("id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ID inválido"})
+ }
+ type Req struct {
+ Nombre string `json:"nombre"`
+ Slug string `json:"slug"`
+ Descripcion string `json:"descripcion"`
+ Orden int `json:"orden"`
+ }
+ var req Req
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Datos inválidos"})
+ }
+ item := &models.DocCategoria{
+ Nombre: req.Nombre,
+ Slug: req.Slug,
+ Descripcion: req.Descripcion,
+ Orden: req.Orden,
+ }
+ item.ID = uint(id)
+ if err := models.UpdateDocCategoria(item); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"message": "Categoría actualizada"})
+}
+
+// DeleteDocCategoria elimina una categoría.
+func DeleteDocCategoria(c *fiber.Ctx) error {
+ id, err := strconv.ParseUint(c.Params("id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ID inválido"})
+ }
+ if err := models.DeleteDocCategoria(uint(id)); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"message": "Categoría eliminada"})
+}
diff --git a/rest/controllers/doc_pagina_controller.go b/rest/controllers/doc_pagina_controller.go
new file mode 100644
index 0000000..825582a
--- /dev/null
+++ b/rest/controllers/doc_pagina_controller.go
@@ -0,0 +1,359 @@
+package controllers
+
+import (
+ "math"
+ "strconv"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
+)
+
+// DocPaginasIndex renderiza el panel de gestión de páginas de documentación.
+func DocPaginasIndex(c *fiber.Ctx) error {
+ data := fiber.Map{
+ "user": c.Locals("user").(map[string]interface{}),
+ "modules": c.Locals("userModules"),
+ }
+ if err := c.Render("docs/paginas", data, "layouts/main"); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return nil
+}
+
+// GetDocPaginas devuelve la lista paginada de páginas en JSON.
+func GetDocPaginas(c *fiber.Ctx) error {
+ page, _ := strconv.Atoi(c.Query("page", "1"))
+ if page < 1 {
+ page = 1
+ }
+ search := c.Query("search", "")
+ limit := 10
+ offset := (page - 1) * limit
+
+ var saasID, catID uint
+ if v, err := strconv.ParseUint(c.Query("saas_id", "0"), 10, 32); err == nil {
+ saasID = uint(v)
+ }
+ if v, err := strconv.ParseUint(c.Query("categoria_id", "0"), 10, 32); err == nil {
+ catID = uint(v)
+ }
+
+ items, total, err := models.GetAllDocPaginas(limit, offset, search, saasID, catID)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ // Selects para el formulario
+ saasItems, _ := models.GetAllSaasProductosSelect()
+ categorias, _ := models.GetAllDocCategoriasSelect()
+ roles, _ := models.AllRolesSelect()
+
+ return c.JSON(fiber.Map{
+ "items": items,
+ "saas": saasItems,
+ "categorias": categorias,
+ "roles": roles,
+ "total": total,
+ "totalPages": int(math.Ceil(float64(total) / float64(limit))),
+ "page": page,
+ "limit": limit,
+ })
+}
+
+// GetDocPaginaDetalle devuelve una página por ID con todos sus datos para edición.
+func GetDocPaginaDetalle(c *fiber.Ctx) error {
+ id, err := strconv.ParseUint(c.Params("id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ID inválido"})
+ }
+ item, err := models.GetDocPaginaByID(uint(id))
+ if err != nil {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Página no encontrada"})
+ }
+ return c.JSON(item)
+}
+
+// CreateDocPagina crea una nueva página de documentación.
+func CreateDocPagina(c *fiber.Ctx) error {
+ type Req struct {
+ SaasID uint `json:"saas_id"`
+ CategoriaID uint `json:"categoria_id"`
+ Titulo string `json:"titulo"`
+ Slug string `json:"slug"`
+ Contenido string `json:"contenido"`
+ TipoContenido string `json:"tipo_contenido"`
+ Visibilidad string `json:"visibilidad"`
+ Orden int `json:"orden"`
+ Publicado bool `json:"publicado"`
+ RolIDs []uint `json:"rol_ids"`
+ }
+ var req Req
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Datos inválidos"})
+ }
+ if req.Titulo == "" || req.Slug == "" || req.SaasID == 0 || req.CategoriaID == 0 {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Título, slug, saas y categoría son obligatorios"})
+ }
+ if req.TipoContenido == "" {
+ req.TipoContenido = "markdown"
+ }
+ if req.Visibilidad == "" {
+ req.Visibilidad = "public"
+ }
+
+ userMap, _ := c.Locals("user").(map[string]interface{})
+ var creadoPor uint
+ if userMap != nil {
+ if idVal, ok := userMap["id"]; ok {
+ switch v := idVal.(type) {
+ case float64:
+ creadoPor = uint(v)
+ case uint:
+ creadoPor = v
+ }
+ }
+ }
+
+ item := &models.DocPagina{
+ SaasID: req.SaasID,
+ CategoriaID: req.CategoriaID,
+ Titulo: req.Titulo,
+ Slug: req.Slug,
+ Contenido: req.Contenido,
+ TipoContenido: req.TipoContenido,
+ Visibilidad: req.Visibilidad,
+ Orden: req.Orden,
+ Publicado: req.Publicado,
+ CreadoPor: creadoPor,
+ }
+ if err := models.CreateDocPagina(item, req.RolIDs); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"message": "Página creada", "item": item})
+}
+
+// UpdateDocPagina actualiza una página existente.
+func UpdateDocPagina(c *fiber.Ctx) error {
+ id, err := strconv.ParseUint(c.Params("id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ID inválido"})
+ }
+ type Req struct {
+ SaasID uint `json:"saas_id"`
+ CategoriaID uint `json:"categoria_id"`
+ Titulo string `json:"titulo"`
+ Slug string `json:"slug"`
+ Contenido string `json:"contenido"`
+ TipoContenido string `json:"tipo_contenido"`
+ Visibilidad string `json:"visibilidad"`
+ Orden int `json:"orden"`
+ Publicado bool `json:"publicado"`
+ RolIDs []uint `json:"rol_ids"`
+ }
+ var req Req
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Datos inválidos"})
+ }
+ item := &models.DocPagina{
+ SaasID: req.SaasID,
+ CategoriaID: req.CategoriaID,
+ Titulo: req.Titulo,
+ Slug: req.Slug,
+ Contenido: req.Contenido,
+ TipoContenido: req.TipoContenido,
+ Visibilidad: req.Visibilidad,
+ Orden: req.Orden,
+ Publicado: req.Publicado,
+ }
+ item.ID = uint(id)
+ if err := models.UpdateDocPagina(item, req.RolIDs); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"message": "Página actualizada"})
+}
+
+// DeleteDocPagina elimina una página de documentación.
+func DeleteDocPagina(c *fiber.Ctx) error {
+ id, err := strconv.ParseUint(c.Params("id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ID inválido"})
+ }
+ if err := models.DeleteDocPagina(uint(id)); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"message": "Página eliminada"})
+}
+
+// ─── Rutas públicas / privadas de lectura ─────────────────────────────────────
+
+// DocsPublicoIndex renderiza el índice público de un SaaS (sin login).
+func DocsPublicoIndex(c *fiber.Ctx) error {
+ saasSlug := c.Params("saas")
+ saas, err := models.GetSaasProductoBySlug(saasSlug)
+ if err != nil {
+ return c.Status(fiber.StatusNotFound).Render("errors/404", fiber.Map{}, "layouts/public")
+ }
+
+ paginas, err := models.GetDocPaginasPublicas(saas.ID)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ // Agrupar por categoría
+ grupos := agruparPorCategoria(paginas)
+
+ return c.Render("docs/public-index", fiber.Map{
+ "saas": saas,
+ "grupos": grupos,
+ }, "layouts/public")
+}
+
+// DocsPublicaPagina renderiza una página pública individual.
+func DocsPublicaPagina(c *fiber.Ctx) error {
+ saasSlug := c.Params("saas")
+ slug := c.Params("slug")
+
+ saas, err := models.GetSaasProductoBySlug(saasSlug)
+ if err != nil {
+ return c.Status(fiber.StatusNotFound).Render("errors/404", fiber.Map{}, "layouts/public")
+ }
+
+ pagina, err := models.GetDocPaginaBySlug(saas.ID, slug)
+ if err != nil || !pagina.Publicado {
+ return c.Status(fiber.StatusNotFound).Render("errors/404", fiber.Map{}, "layouts/public")
+ }
+ if pagina.Visibilidad == "private" {
+ return c.Status(fiber.StatusForbidden).Render("errors/403", fiber.Map{}, "layouts/public")
+ }
+
+ // Índice lateral: páginas públicas del mismo SaaS
+ todas, _ := models.GetDocPaginasPublicas(saas.ID)
+ grupos := agruparPorCategoria(todas)
+
+ return c.Render("docs/public-page", fiber.Map{
+ "saas": saas,
+ "pagina": pagina,
+ "grupos": grupos,
+ }, "layouts/public")
+}
+
+// DocsPrivadoIndex renderiza el índice de un SaaS para usuarios autenticados (incluye privadas con rol).
+func DocsPrivadoIndex(c *fiber.Ctx) error {
+ saasSlug := c.Params("saas")
+ saas, err := models.GetSaasProductoBySlug(saasSlug)
+ if err != nil {
+ return c.Status(fiber.StatusNotFound).Render("errors/404", fiber.Map{}, "layouts/main")
+ }
+
+ rolNames := getUserRolNames(c)
+ paginas, err := models.GetDocPaginasParaRoles(saas.ID, rolNames)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ grupos := agruparPorCategoria(paginas)
+
+ return c.Render("docs/private-index", fiber.Map{
+ "user": c.Locals("user"),
+ "saas": saas,
+ "grupos": grupos,
+ }, "layouts/main")
+}
+
+// DocsPrivadaPagina renderiza una página con verificación de rol.
+func DocsPrivadaPagina(c *fiber.Ctx) error {
+ saasSlug := c.Params("saas")
+ slug := c.Params("slug")
+
+ saas, err := models.GetSaasProductoBySlug(saasSlug)
+ if err != nil {
+ return c.Status(fiber.StatusNotFound).Render("errors/404", fiber.Map{}, "layouts/main")
+ }
+
+ pagina, err := models.GetDocPaginaBySlug(saas.ID, slug)
+ if err != nil || !pagina.Publicado {
+ return c.Status(fiber.StatusNotFound).Render("errors/404", fiber.Map{}, "layouts/main")
+ }
+
+ if pagina.Visibilidad == "private" {
+ // Verificar que el usuario tenga al menos un rol asignado a la página
+ rolNames := getUserRolNames(c)
+ if !paginaAccesible(pagina, rolNames) {
+ return c.Status(fiber.StatusForbidden).Render("errors/403", fiber.Map{
+ "user": c.Locals("user"),
+ }, "layouts/main")
+ }
+ }
+
+ rolNames := getUserRolNames(c)
+ todas, _ := models.GetDocPaginasParaRoles(saas.ID, rolNames)
+ grupos := agruparPorCategoria(todas)
+
+ return c.Render("docs/private-page", fiber.Map{
+ "user": c.Locals("user"),
+ "modules": c.Locals("userModules"),
+ "saas": saas,
+ "pagina": pagina,
+ "grupos": grupos,
+ }, "layouts/main")
+}
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+type GrupoCategoria struct {
+ Categoria models.DocCategoria
+ Paginas []models.DocPagina
+}
+
+func agruparPorCategoria(paginas []models.DocPagina) []GrupoCategoria {
+ orden := []uint{}
+ mapa := map[uint]*GrupoCategoria{}
+ for _, p := range paginas {
+ if _, ok := mapa[p.CategoriaID]; !ok {
+ orden = append(orden, p.CategoriaID)
+ mapa[p.CategoriaID] = &GrupoCategoria{Categoria: p.Categoria}
+ }
+ mapa[p.CategoriaID].Paginas = append(mapa[p.CategoriaID].Paginas, p)
+ }
+ var result []GrupoCategoria
+ for _, id := range orden {
+ result = append(result, *mapa[id])
+ }
+ return result
+}
+
+func getUserRolNames(c *fiber.Ctx) []string {
+ userMap, ok := c.Locals("user").(map[string]interface{})
+ if !ok {
+ return nil
+ }
+ raw, ok := userMap["roles"]
+ if !ok {
+ return nil
+ }
+ var names []string
+ switch v := raw.(type) {
+ case []string:
+ names = v
+ case []interface{}:
+ for _, r := range v {
+ if s, ok := r.(string); ok {
+ names = append(names, s)
+ }
+ }
+ }
+ return names
+}
+
+func paginaAccesible(p *models.DocPagina, userRoles []string) bool {
+ if p.Visibilidad == "public" {
+ return true
+ }
+ for _, pr := range p.Roles {
+ for _, ur := range userRoles {
+ if pr.Name == ur {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/rest/controllers/saas_controller.go b/rest/controllers/saas_controller.go
new file mode 100644
index 0000000..cbdcdd3
--- /dev/null
+++ b/rest/controllers/saas_controller.go
@@ -0,0 +1,128 @@
+package controllers
+
+import (
+ "math"
+ "strconv"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
+)
+
+// SaasIndex renderiza la vista del panel de gestión de productos SaaS.
+func SaasIndex(c *fiber.Ctx) error {
+ data := fiber.Map{
+ "user": c.Locals("user").(map[string]interface{}),
+ "modules": c.Locals("userModules"),
+ }
+ if err := c.Render("saas", data, "layouts/main"); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return nil
+}
+
+// GetSaasProductos devuelve la lista paginada en JSON (para Alpine/HTMX).
+func GetSaasProductos(c *fiber.Ctx) error {
+ page, _ := strconv.Atoi(c.Query("page", "1"))
+ if page < 1 {
+ page = 1
+ }
+ search := c.Query("search", "")
+ limit := 10
+ offset := (page - 1) * limit
+
+ items, total, err := models.GetAllSaasProductos(limit, offset, search)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ servicios, _ := models.GetAllServiciosSelect()
+
+ return c.JSON(fiber.Map{
+ "items": items,
+ "servicios": servicios,
+ "total": total,
+ "totalPages": int(math.Ceil(float64(total) / float64(limit))),
+ "page": page,
+ "limit": limit,
+ })
+}
+
+// CreateSaasProducto crea un nuevo producto SaaS.
+func CreateSaasProducto(c *fiber.Ctx) error {
+ type Req struct {
+ Nombre string `json:"nombre"`
+ Slug string `json:"slug"`
+ Descripcion string `json:"descripcion"`
+ LogoURL string `json:"logo_url"`
+ ServicioID *uint `json:"servicio_id"`
+ Activo bool `json:"activo"`
+ Orden int `json:"orden"`
+ }
+ var req Req
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Datos inválidos"})
+ }
+ if req.Nombre == "" || req.Slug == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Nombre y slug son obligatorios"})
+ }
+ item := &models.SaasProducto{
+ Nombre: req.Nombre,
+ Slug: req.Slug,
+ Descripcion: req.Descripcion,
+ LogoURL: req.LogoURL,
+ ServicioID: req.ServicioID,
+ Activo: req.Activo,
+ Orden: req.Orden,
+ }
+ if err := models.CreateSaasProducto(item); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"message": "Producto SaaS creado", "item": item})
+}
+
+// UpdateSaasProducto actualiza un producto SaaS existente.
+func UpdateSaasProducto(c *fiber.Ctx) error {
+ id, err := strconv.ParseUint(c.Params("id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ID inválido"})
+ }
+ type Req struct {
+ Nombre string `json:"nombre"`
+ Slug string `json:"slug"`
+ Descripcion string `json:"descripcion"`
+ LogoURL string `json:"logo_url"`
+ ServicioID *uint `json:"servicio_id"`
+ Activo bool `json:"activo"`
+ Orden int `json:"orden"`
+ }
+ var req Req
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Datos inválidos"})
+ }
+ item := &models.SaasProducto{
+ Nombre: req.Nombre,
+ Slug: req.Slug,
+ Descripcion: req.Descripcion,
+ LogoURL: req.LogoURL,
+ ServicioID: req.ServicioID,
+ Activo: req.Activo,
+ Orden: req.Orden,
+ }
+ item.ID = uint(id)
+ if err := models.UpdateSaasProducto(item); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"message": "Producto SaaS actualizado"})
+}
+
+// DeleteSaasProducto elimina un producto SaaS.
+func DeleteSaasProducto(c *fiber.Ctx) error {
+ id, err := strconv.ParseUint(c.Params("id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ID inválido"})
+ }
+ if err := models.DeleteSaasProducto(uint(id)); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"message": "Producto SaaS eliminado"})
+}
diff --git a/rest/routes/publicas.go b/rest/routes/publicas.go
index fc60cc4..6afbe2b 100755
--- a/rest/routes/publicas.go
+++ b/rest/routes/publicas.go
@@ -25,5 +25,7 @@ func RutasPublicas(web fiber.Router) {
web.Get("/pago-exitoso", apiControllers.PagoExitosoPage)
// Ruta fuera del prefijo /api para evitar que AuthApi() la intercepte
web.Get("/pago/estado", apiControllers.PagoEstadoAPI)
-}
+ // ─── Documentación pública ────────────────────────────────────────────────
+ web.Get("/docs/:saas", controllers.DocsPublicoIndex)
+ web.Get("/docs/:saas/:slug", controllers.DocsPublicaPagina)}
diff --git a/rest/routes/user.go b/rest/routes/user.go
index 02e37f6..ccd34a7 100755
--- a/rest/routes/user.go
+++ b/rest/routes/user.go
@@ -144,4 +144,30 @@ func UserRoutes(app fiber.Router) {
// Bold API (crear link, consultar estado)
protected.Post("/pasarelas/bold/crear-link", apiControllers.BoldCreatePaymentLink)
protected.Get("/pasarelas/bold/link/:linkID", apiControllers.BoldGetLinkStatus)
+
+ // ─── Productos SaaS ──────────────────────────────────────────────────────
+ protected.Get("/saas", middlewares.MenuMiddleware, controllers.SaasIndex)
+ protected.Get("/loadsaas", controllers.GetSaasProductos)
+ protected.Post("/saas", controllers.CreateSaasProducto)
+ protected.Put("/saas/:id", controllers.UpdateSaasProducto)
+ protected.Delete("/saas/:id", controllers.DeleteSaasProducto)
+
+ // ─── Documentación: categorías globales ──────────────────────────────────
+ protected.Get("/doc/categorias", middlewares.MenuMiddleware, controllers.DocCategoriasIndex)
+ protected.Get("/doc/loadcategorias", controllers.GetDocCategorias)
+ protected.Post("/doc/categorias", controllers.CreateDocCategoria)
+ protected.Put("/doc/categorias/:id", controllers.UpdateDocCategoria)
+ protected.Delete("/doc/categorias/:id", controllers.DeleteDocCategoria)
+
+ // ─── Documentación: páginas ───────────────────────────────────────────────
+ protected.Get("/doc/paginas", middlewares.MenuMiddleware, controllers.DocPaginasIndex)
+ protected.Get("/doc/loadpaginas", controllers.GetDocPaginas)
+ protected.Get("/doc/paginas/:id", controllers.GetDocPaginaDetalle)
+ protected.Post("/doc/paginas", controllers.CreateDocPagina)
+ protected.Put("/doc/paginas/:id", controllers.UpdateDocPagina)
+ protected.Delete("/doc/paginas/:id", controllers.DeleteDocPagina)
+
+ // ─── Documentación: lectura privada (usuario logueado) ───────────────────
+ protected.Get("/docs/:saas", middlewares.MenuMiddleware, controllers.DocsPrivadoIndex)
+ protected.Get("/docs/:saas/:slug", middlewares.MenuMiddleware, controllers.DocsPrivadaPagina)
}