From 2b7458b612a2d54ac1a42ade55cde642ecf96891 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 16 May 2026 20:01:36 -0500 Subject: [PATCH] up --- migrations/migrate.go | 45 ++ pkg/models/partner_recurso.go | 91 ++++ resources/views/partner_recursos.html | 387 ++++++++++++++++++ resources/views/portal/dashboard.html | 132 ++++++ .../controllers/partner_recurso_controller.go | 285 +++++++++++++ rest/middlewares/portal_auth.go | 11 + rest/routes/portal.go | 5 + rest/routes/user.go | 16 + 8 files changed, 972 insertions(+) create mode 100644 pkg/models/partner_recurso.go create mode 100644 resources/views/partner_recursos.html create mode 100644 rest/controllers/partner_recurso_controller.go diff --git a/migrations/migrate.go b/migrations/migrate.go index 2fc7f2c..c9f2455 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -85,6 +85,9 @@ func Migrate() { // Sistema de notificaciones por evento &models.NotifEventoConfig{}, &models.SistemaNotificacion{}, + // Submódulo Partner + &models.PartnerRecurso{}, + &models.PartnerComunicado{}, ); err != nil { log.Fatalf("Error during main migration: %v", err) } @@ -128,6 +131,9 @@ func Migrate() { // Crear módulo y submódulos de Portal de Clientes SeedPortalClientes() + // Agregar submódulo "Partner Recursos" al módulo Portal de Clientes + SeedPartnerRecursos() + log.Println("Migration Completed...") } @@ -846,3 +852,42 @@ func SeedNotifDefaults() { func SeedShield() { log.Println("[SEED] SeedShield: sin entradas por defecto definidas aún.") } + +// SeedPartnerRecursos agrega el submódulo "Partner Recursos" al módulo "Portal de Clientes". Es idempotente. +func SeedPartnerRecursos() { + db := app.Http.Database.DB + + var modulo models.Modules + if err := db.Where("title = ?", "Portal de Clientes").First(&modulo).Error; err != nil { + log.Printf("[SEED] Módulo 'Portal de Clientes' no encontrado para SeedPartnerRecursos: %v", err) + return + } + + url := "/app/partner-recursos" + var sub models.Submodules + if err := db.Where("url = ?", url).First(&sub).Error; err != nil { + sub = models.Submodules{ + Title: "Partner Recursos", + Description: "Documentación y comunicados exclusivos para partners", + Url: url, + ModuleId: modulo.ID, + ModifiedAt: time.Now(), + } + if err2 := db.Create(&sub).Error; err2 != nil { + log.Printf("[SEED] Error creando submódulo Partner Recursos: %v", err2) + return + } + log.Printf("[SEED] Submódulo 'Partner Recursos' creado (ID %d)", sub.ID) + } else { + log.Printf("[SEED] Submódulo 'Partner Recursos' ya existe (ID %d)", sub.ID) + } + + var roles []models.Roles + if err := db.Find(&roles).Error; err != nil { + return + } + for _, rol := range roles { + db.Model(&rol).Association("Submodules").Append(&sub) + } + log.Println("[SEED] Seed de Partner Recursos completado.") +} diff --git a/pkg/models/partner_recurso.go b/pkg/models/partner_recurso.go new file mode 100644 index 0000000..f441ee9 --- /dev/null +++ b/pkg/models/partner_recurso.go @@ -0,0 +1,91 @@ +package models + +import ( + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// ─── PartnerRecurso ─────────────────────────────────────────────────────────── +// Archivos/documentos que el admin sube para que los partners descarguen. + +type PartnerRecurso struct { + gorm.Model + Nombre string `json:"nombre" gorm:"column:nombre"` + Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"` + Categoria string `json:"categoria" gorm:"column:categoria;default:'general'"` // general|tecnico|comercial|marketing + Archivo string `json:"archivo" gorm:"column:archivo"` + NombreOrig string `json:"nombre_orig" gorm:"column:nombre_orig"` + Activo bool `json:"activo" gorm:"column:activo;default:true"` +} + +func (PartnerRecurso) TableName() string { return "partner_recursos" } + +// ─── PartnerComunicado ──────────────────────────────────────────────────────── +// Anuncios/novedades publicados por el admin exclusivamente para partners. + +type PartnerComunicado struct { + gorm.Model + Titulo string `json:"titulo" gorm:"column:titulo"` + Contenido string `json:"contenido" gorm:"column:contenido;type:text"` + Archivo string `json:"archivo" gorm:"column:archivo"` + NombreOrig string `json:"nombre_orig" gorm:"column:nombre_orig"` + Activo bool `json:"activo" gorm:"column:activo;default:true"` +} + +func (PartnerComunicado) TableName() string { return "partner_comunicados" } + +// ─── CRUD PartnerRecurso ────────────────────────────────────────────────────── + +func GetAllPartnerRecursos(soloActivos bool) ([]PartnerRecurso, error) { + var items []PartnerRecurso + db := app.Http.Database.DB.Order("created_at DESC") + if soloActivos { + db = db.Where("activo = true") + } + return items, db.Find(&items).Error +} + +func GetPartnerRecursoByID(id uint) (*PartnerRecurso, error) { + var item PartnerRecurso + return &item, app.Http.Database.DB.First(&item, id).Error +} + +func CreatePartnerRecurso(r *PartnerRecurso) error { + return app.Http.Database.DB.Create(r).Error +} + +func UpdatePartnerRecurso(r *PartnerRecurso) error { + return app.Http.Database.DB.Save(r).Error +} + +func DeletePartnerRecurso(id uint) error { + return app.Http.Database.DB.Delete(&PartnerRecurso{}, id).Error +} + +// ─── CRUD PartnerComunicado ─────────────────────────────────────────────────── + +func GetAllPartnerComunicados(soloActivos bool) ([]PartnerComunicado, error) { + var items []PartnerComunicado + db := app.Http.Database.DB.Order("created_at DESC") + if soloActivos { + db = db.Where("activo = true") + } + return items, db.Find(&items).Error +} + +func GetPartnerComunicadoByID(id uint) (*PartnerComunicado, error) { + var item PartnerComunicado + return &item, app.Http.Database.DB.First(&item, id).Error +} + +func CreatePartnerComunicado(c *PartnerComunicado) error { + return app.Http.Database.DB.Create(c).Error +} + +func UpdatePartnerComunicado(c *PartnerComunicado) error { + return app.Http.Database.DB.Save(c).Error +} + +func DeletePartnerComunicado(id uint) error { + return app.Http.Database.DB.Delete(&PartnerComunicado{}, id).Error +} diff --git a/resources/views/partner_recursos.html b/resources/views/partner_recursos.html new file mode 100644 index 0000000..30b6f97 --- /dev/null +++ b/resources/views/partner_recursos.html @@ -0,0 +1,387 @@ +
+ + +
+
+

Partner Recursos

+

Documentación y comunicados exclusivos para los usuarios partner del portal.

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

Agregar recurso

+
+ + + + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + +
NombreArchivoVisibleAcciones
Sin recursos aún.
+
+
+ + +
+ + +
+

Publicar comunicado

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

Sin comunicados publicados.

+
+
+ +
+ + + + diff --git a/resources/views/portal/dashboard.html b/resources/views/portal/dashboard.html index 0f3de9d..aadbef1 100644 --- a/resources/views/portal/dashboard.html +++ b/resources/views/portal/dashboard.html @@ -80,3 +80,135 @@ {{ end }} + +{{ if .isPartner }} + +
+ +
+ P +
+

Zona Partner

+

Recursos y comunicados exclusivos para ti

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

Sin comunicados por ahora.

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

Sin recursos disponibles.

+
+
+
+{{ end }} + + diff --git a/rest/controllers/partner_recurso_controller.go b/rest/controllers/partner_recurso_controller.go new file mode 100644 index 0000000..19ffe6e --- /dev/null +++ b/rest/controllers/partner_recurso_controller.go @@ -0,0 +1,285 @@ +package controllers + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// ─── Admin: vista principal ─────────────────────────────────────────────────── + +func PartnerRecursosIndex(c *fiber.Ctx) error { + return c.Render("partner_recursos", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +// ─── Recursos ───────────────────────────────────────────────────────────────── + +func LoadPartnerRecursos(c *fiber.Ctx) error { + recursos, err := models.GetAllPartnerRecursos(false) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"recursos": recursos}) +} + +func CreatePartnerRecurso(c *fiber.Ctx) error { + type Req struct { + Nombre string `json:"nombre"` + Descripcion string `json:"descripcion"` + Categoria string `json:"categoria"` + Activo bool `json:"activo"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Datos inválidos"}) + } + r := &models.PartnerRecurso{ + Nombre: req.Nombre, + Descripcion: req.Descripcion, + Categoria: req.Categoria, + Activo: req.Activo, + } + if err := models.CreatePartnerRecurso(r); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(r) +} + +func UpdatePartnerRecurso(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + type Req struct { + Nombre string `json:"nombre"` + Descripcion string `json:"descripcion"` + Categoria string `json:"categoria"` + Activo bool `json:"activo"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Datos inválidos"}) + } + r, err := models.GetPartnerRecursoByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) + } + r.Nombre = req.Nombre + r.Descripcion = req.Descripcion + r.Categoria = req.Categoria + r.Activo = req.Activo + if err := models.UpdatePartnerRecurso(r); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(r) +} + +func DeletePartnerRecurso(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + if err := models.DeletePartnerRecurso(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func UploadPartnerRecursoArchivo(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + file, err := c.FormFile("archivo") + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Archivo requerido"}) + } + if file.Size > 50*1024*1024 { + return c.Status(400).JSON(fiber.Map{"error": "Máximo 50MB"}) + } + dir := "uploads/partner_recursos" + if err := os.MkdirAll(dir, 0755); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Error al crear directorio"}) + } + safeFile := safeFilenameProyecto(file.Filename) + savePath := filepath.Join(dir, fmt.Sprintf("%d_%s", id, safeFile)) + // Validar que la ruta quede dentro de uploads/ + clean := filepath.Clean(savePath) + if !strings.HasPrefix(clean, "uploads/") { + return c.Status(400).JSON(fiber.Map{"error": "Ruta inválida"}) + } + if err := c.SaveFile(file, savePath); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Error al guardar archivo"}) + } + r, err := models.GetPartnerRecursoByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "Recurso no encontrado"}) + } + r.Archivo = savePath + r.NombreOrig = file.Filename + if err := models.UpdatePartnerRecurso(r); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true, "archivo": savePath, "nombre_orig": file.Filename}) +} + +func DownloadPartnerRecurso(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + r, err := models.GetPartnerRecursoByID(uint(id)) + if err != nil || r.Archivo == "" { + return c.Status(404).JSON(fiber.Map{"error": "Archivo no encontrado"}) + } + clean := filepath.Clean(r.Archivo) + if !strings.HasPrefix(clean, "uploads/") { + return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"}) + } + nombre := r.NombreOrig + if nombre == "" { + nombre = filepath.Base(r.Archivo) + } + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, nombre)) + return c.SendFile(clean) +} + +// ─── Comunicados ────────────────────────────────────────────────────────────── + +func LoadPartnerComunicados(c *fiber.Ctx) error { + items, err := models.GetAllPartnerComunicados(false) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"comunicados": items}) +} + +func CreatePartnerComunicado(c *fiber.Ctx) error { + type Req struct { + Titulo string `json:"titulo"` + Contenido string `json:"contenido"` + Activo bool `json:"activo"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Datos inválidos"}) + } + item := &models.PartnerComunicado{ + Titulo: req.Titulo, + Contenido: req.Contenido, + Activo: req.Activo, + } + if err := models.CreatePartnerComunicado(item); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(item) +} + +func UpdatePartnerComunicado(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + type Req struct { + Titulo string `json:"titulo"` + Contenido string `json:"contenido"` + Activo bool `json:"activo"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Datos inválidos"}) + } + item, err := models.GetPartnerComunicadoByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) + } + item.Titulo = req.Titulo + item.Contenido = req.Contenido + item.Activo = req.Activo + if err := models.UpdatePartnerComunicado(item); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(item) +} + +func DeletePartnerComunicado(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + if err := models.DeletePartnerComunicado(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func UploadPartnerComunicadoArchivo(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + file, err := c.FormFile("archivo") + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Archivo requerido"}) + } + if file.Size > 50*1024*1024 { + return c.Status(400).JSON(fiber.Map{"error": "Máximo 50MB"}) + } + dir := "uploads/partner_comunicados" + if err := os.MkdirAll(dir, 0755); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Error al crear directorio"}) + } + safeFile := safeFilenameProyecto(file.Filename) + savePath := filepath.Join(dir, fmt.Sprintf("%d_%s", id, safeFile)) + clean := filepath.Clean(savePath) + if !strings.HasPrefix(clean, "uploads/") { + return c.Status(400).JSON(fiber.Map{"error": "Ruta inválida"}) + } + if err := c.SaveFile(file, savePath); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Error al guardar archivo"}) + } + item, err := models.GetPartnerComunicadoByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "Comunicado no encontrado"}) + } + item.Archivo = savePath + item.NombreOrig = file.Filename + if err := models.UpdatePartnerComunicado(item); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true, "archivo": savePath, "nombre_orig": file.Filename}) +} + +func DownloadPartnerComunicadoArchivo(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + item, err := models.GetPartnerComunicadoByID(uint(id)) + if err != nil || item.Archivo == "" { + return c.Status(404).JSON(fiber.Map{"error": "Archivo no encontrado"}) + } + clean := filepath.Clean(item.Archivo) + if !strings.HasPrefix(clean, "uploads/") { + return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"}) + } + nombre := item.NombreOrig + if nombre == "" { + nombre = filepath.Base(item.Archivo) + } + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, nombre)) + return c.SendFile(clean) +} + +// ─── Portal: acceso para partners ──────────────────────────────────────────── + +func PortalPartnerRecursos(c *fiber.Ctx) error { + recursos, _ := models.GetAllPartnerRecursos(true) + comunicados, _ := models.GetAllPartnerComunicados(true) + return c.JSON(fiber.Map{ + "recursos": recursos, + "comunicados": comunicados, + }) +} + +func PortalDownloadPartnerRecurso(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + r, err := models.GetPartnerRecursoByID(uint(id)) + if err != nil || r.Archivo == "" || !r.Activo { + return c.Status(404).JSON(fiber.Map{"error": "Archivo no encontrado"}) + } + clean := filepath.Clean(r.Archivo) + if !strings.HasPrefix(clean, "uploads/") { + return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"}) + } + nombre := r.NombreOrig + if nombre == "" { + nombre = filepath.Base(r.Archivo) + } + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, nombre)) + return c.SendFile(clean) +} diff --git a/rest/middlewares/portal_auth.go b/rest/middlewares/portal_auth.go index bf41fc4..44e799f 100644 --- a/rest/middlewares/portal_auth.go +++ b/rest/middlewares/portal_auth.go @@ -35,3 +35,14 @@ func PortalUserFromLocals(c *fiber.Ctx) *models.PortalUser { u, _ := c.Locals("portalUser").(*models.PortalUser) return u } + +// PortalPartnerOnly permite el acceso solo a usuarios con rol "partner". +func PortalPartnerOnly() fiber.Handler { + return func(c *fiber.Ctx) error { + u := PortalUserFromLocals(c) + if u == nil || u.Rol != "partner" { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Acceso exclusivo para partners"}) + } + return c.Next() + } +} diff --git a/rest/routes/portal.go b/rest/routes/portal.go index f3acc9f..7c7fe9f 100644 --- a/rest/routes/portal.go +++ b/rest/routes/portal.go @@ -35,4 +35,9 @@ func PortalRoutes(app fiber.Router) { portal.Get("/mis-notifs", controllers.PortalGetMisNotifs) portal.Put("/mis-notifs/:id/leida", controllers.PortalMarcarNotifLeida) portal.Post("/mis-notifs/marcar-todas", controllers.PortalMarcarTodasLeidas) + + // Partner: recursos y comunicados (solo usuarios con rol partner) + portal.Get("/partner-recursos", middlewares.PortalPartnerOnly(), controllers.PortalPartnerRecursos) + portal.Get("/partner-recursos/:id/download", middlewares.PortalPartnerOnly(), controllers.PortalDownloadPartnerRecurso) + portal.Get("/partner-comunicados/:id/download", middlewares.PortalPartnerOnly(), controllers.DownloadPartnerComunicadoArchivo) } diff --git a/rest/routes/user.go b/rest/routes/user.go index 0dcf6de..479b9f8 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -295,6 +295,22 @@ func UserRoutes(app fiber.Router) { protected.Get("/notif-config/data", controllers.GetNotifConfigs) protected.Post("/notif-config", controllers.SaveNotifConfig) + // Partner Recursos (documentación y comunicados para partners) + protected.Get("/partner-recursos", middlewares.MenuMiddleware, controllers.PartnerRecursosIndex) + protected.Get("/partner-recursos/data", controllers.LoadPartnerRecursos) + protected.Post("/partner-recursos", controllers.CreatePartnerRecurso) + protected.Put("/partner-recursos/:id", controllers.UpdatePartnerRecurso) + protected.Delete("/partner-recursos/:id", controllers.DeletePartnerRecurso) + protected.Post("/partner-recursos/:id/upload", controllers.UploadPartnerRecursoArchivo) + protected.Get("/partner-recursos/:id/download", controllers.DownloadPartnerRecurso) + + protected.Get("/partner-comunicados/data", controllers.LoadPartnerComunicados) + protected.Post("/partner-comunicados", controllers.CreatePartnerComunicado) + protected.Put("/partner-comunicados/:id", controllers.UpdatePartnerComunicado) + protected.Delete("/partner-comunicados/:id", controllers.DeletePartnerComunicado) + protected.Post("/partner-comunicados/:id/upload", controllers.UploadPartnerComunicadoArchivo) + protected.Get("/partner-comunicados/:id/download", controllers.DownloadPartnerComunicadoArchivo) + // Notificaciones in-app del admin (bell) protected.Get("/mis-notifs", controllers.GetMisNotifs) protected.Put("/mis-notifs/:id/leida", controllers.MarcarNotifLeida)