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".

+ +
+
+ +
+ + + + +
+
+ +
+ +
+ + + + + + + + + + + + + + +
NombreSlugDescripciónOrden
+
+ + +
+ Total: +
+ +
+
+
+ + +
+
+

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+

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ítuloSaaSCategoríaTipoVisibilidadEstado
+
+ + +
+ Total: +
+ +
+
+
+ + +
+
+
+

+ +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+
+
+
+ + +
+
+

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}} + {{.saas.Nombre}} + {{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}} + {{.saas.Nombre}} + {{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 @@ + + + + + + +
+ +
+
+ + + + + {{.saas.Nombre}} + + / + {{.pagina.Titulo}} +
+
+ +
+ + + + + +
+
+
+
+ {{.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.

+ +
+ +
+ +
+ + + + +
+
+ +
+ + +
+ + + + + + + + + + + + + + + +
NombreSlugServicio vinculadoOrdenEstado
+
+ + +
+ Total: +
+ +
+
+
+ + +
+
+

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+

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) }