diff --git a/migrations/migrate.go b/migrations/migrate.go index fc6831c..2fc7f2c 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -82,6 +82,9 @@ func Migrate() { &models.ProyectoTicket{}, &models.TicketMensaje{}, &models.Factura{}, + // Sistema de notificaciones por evento + &models.NotifEventoConfig{}, + &models.SistemaNotificacion{}, ); err != nil { log.Fatalf("Error during main migration: %v", err) } @@ -119,6 +122,12 @@ func Migrate() { // Crear plantillas y reglas mínimas si no existen (bienvenida, pago_recibido) SeedPlantillasBase() + // Agregar submódulo "Telegram" al módulo Integraciones + SeedTelegram() + + // Crear módulo y submódulos de Portal de Clientes + SeedPortalClientes() + log.Println("Migration Completed...") } diff --git a/pkg/auth/portal.go b/pkg/auth/portal.go index e51a6f6..93a5c00 100644 --- a/pkg/auth/portal.go +++ b/pkg/auth/portal.go @@ -44,9 +44,13 @@ func PortalUser(c *fiber.Ctx) (*models.PortalUser, error) { } // SetPortalSession guarda el portal_user_id en la sesión. +// Regenera el ID de sesión para prevenir session fixation. func SetPortalSession(c *fiber.Ctx, userID uint) error { store := app.Http.Session.Get(c) store.Set(portalSessionKey, userID) + if err := store.Regenerate(); err != nil { + return err + } return store.Save() } diff --git a/pkg/models/portal_user.go b/pkg/models/portal_user.go index f84ea86..80fac50 100644 --- a/pkg/models/portal_user.go +++ b/pkg/models/portal_user.go @@ -19,11 +19,15 @@ type PortalUser struct { Password string `json:"-" gorm:"column:password;type:text"` ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"` // nil si es partner Cliente *Cliente `json:"cliente" gorm:"foreignKey:ClienteID"` - Rol string `json:"rol" gorm:"column:rol;default:'cliente'"` // cliente|partner - Activo bool `json:"activo" gorm:"column:activo;default:true"` - Notas string `json:"notas" gorm:"column:notas;type:text"` - TelegramChatID string `json:"telegram_chat_id" gorm:"column:telegram_chat_id"` - PortalAccesos []PortalAcceso `json:"portal_accesos" gorm:"foreignKey:PortalUserID"` + Rol string `json:"rol" gorm:"column:rol;default:'cliente'"` // cliente|partner + RoleID *uint `json:"role_id" gorm:"column:role_id;index"` + Role *Roles `json:"role" gorm:"foreignKey:RoleID"` + Activo bool `json:"activo" gorm:"column:activo;default:true"` + Notas string `json:"notas" gorm:"column:notas;type:text"` + TelegramChatID string `json:"telegram_chat_id" gorm:"column:telegram_chat_id"` + Telefono string `json:"telefono" gorm:"column:telefono"` + Pais string `json:"pais" gorm:"column:pais"` + PortalAccesos []PortalAcceso `json:"portal_accesos" gorm:"foreignKey:PortalUserID"` } func (PortalUser) TableName() string { return "portal_users" } @@ -44,7 +48,7 @@ func (PortalAcceso) TableName() string { return "portal_accesos" } func GetAllPortalUsers(limit, offset int) ([]PortalUser, int64, error) { var items []PortalUser var total int64 - db := app.Http.Database.DB.Model(&PortalUser{}).Preload("Cliente").Preload("PortalAccesos.Cliente") + db := app.Http.Database.DB.Model(&PortalUser{}).Preload("Cliente").Preload("PortalAccesos.Cliente").Preload("Role") if err := db.Count(&total).Error; err != nil { return nil, 0, err } @@ -56,7 +60,7 @@ func GetAllPortalUsers(limit, offset int) ([]PortalUser, int64, error) { func GetPortalUserByID(id uint) (*PortalUser, error) { var item PortalUser - err := app.Http.Database.DB.Preload("Cliente").Preload("PortalAccesos.Cliente").First(&item, id).Error + err := app.Http.Database.DB.Preload("Cliente").Preload("PortalAccesos.Cliente").Preload("Role").First(&item, id).Error return &item, err } @@ -76,9 +80,12 @@ func UpdatePortalUser(u *PortalUser) error { "email": u.Email, "cliente_id": u.ClienteID, "rol": u.Rol, - "activo": u.Activo, - "notas": u.Notas, + "role_id": u.RoleID, + "activo": u.Activo, + "notas": u.Notas, "telegram_chat_id": u.TelegramChatID, + "telefono": u.Telefono, + "pais": u.Pais, }).Error } @@ -149,7 +156,11 @@ func RemovePortalAcceso(portalUserID, clienteID uint) error { // GetClienteIDsForPortalUser devuelve todos los clienteIDs accesibles para un portal user. func GetClienteIDsForPortalUser(u *PortalUser) []uint { - if u.Rol == "partner" { + isPartner := u.Rol == "partner" + if u.Role != nil { + isPartner = u.Role.EsPortalPartner + } + if isPartner { ids := make([]uint, 0, len(u.PortalAccesos)) for _, a := range u.PortalAccesos { ids = append(ids, a.ClienteID) diff --git a/pkg/models/proyecto.go b/pkg/models/proyecto.go index 2e57fca..4afb518 100644 --- a/pkg/models/proyecto.go +++ b/pkg/models/proyecto.go @@ -124,8 +124,11 @@ func GetAllProyectos(limit, offset int, search string) ([]Proyecto, int64, error } func GetProyectosByClienteIDs(clienteIDs []uint) ([]Proyecto, error) { + if len(clienteIDs) == 0 { + return []Proyecto{}, nil + } var items []Proyecto - err := app.Http.Database.DB.Where("cliente_id IN ?", clienteIDs).Order("created_at DESC").Find(&items).Error + err := app.Http.Database.DB.Preload("Cliente").Where("cliente_id IN ?", clienteIDs).Order("created_at DESC").Find(&items).Error return items, err } diff --git a/rest/controllers/factura_controller.go b/rest/controllers/factura_controller.go index ab08d70..ecbdeeb 100644 --- a/rest/controllers/factura_controller.go +++ b/rest/controllers/factura_controller.go @@ -11,6 +11,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" ) func FacturasIndex(c *fiber.Ctx) error { @@ -185,6 +186,10 @@ func UploadFacturaPDF(c *fiber.Ctx) error { if err := models.UpdateFacturaArchivo(uint(id), savePath, file.Filename); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } + // Notificar al cliente + if f, err := models.GetFacturaByID(uint(id)); err == nil { + go services.DispatchFacturaSubida(f) + } return c.JSON(fiber.Map{"ok": true, "archivo": savePath}) } diff --git a/rest/controllers/portal_controller.go b/rest/controllers/portal_controller.go index 7457847..9d136f1 100644 --- a/rest/controllers/portal_controller.go +++ b/rest/controllers/portal_controller.go @@ -8,13 +8,14 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/auth" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" "github.com/sujit-baniya/fiber-boilerplate/rest/middlewares" ) // ─── Auth ───────────────────────────────────────────────────────────────────── func PortalLoginPage(c *fiber.Ctx) error { - if auth.IsPortalLoggedIn(c) { + if user, err := auth.PortalUser(c); err == nil && user != nil { return c.Redirect("/portal/dashboard") } return c.Render("portal/login", fiber.Map{ @@ -220,6 +221,7 @@ func PortalCrearTicket(c *fiber.Ctx) error { if err := models.CreateProyectoTicket(t); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } + go services.DispatchTicketNuevo(t, u, proy.Nombre) return c.Status(201).JSON(t) } @@ -254,6 +256,13 @@ func PortalResponderTicket(c *fiber.Ctx) error { if err := models.CreateTicketMensaje(msg); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } + go func() { + proyNombre := "" + if proy, err := models.GetProyectoByID(ticket.ProyectoID); err == nil { + proyNombre = proy.Nombre + } + services.DispatchTicketRespuestaCliente(ticket, req.Contenido, proyNombre) + }() return c.Status(201).JSON(msg) } diff --git a/rest/controllers/portal_usuario_controller.go b/rest/controllers/portal_usuario_controller.go index 67ae10a..222419e 100644 --- a/rest/controllers/portal_usuario_controller.go +++ b/rest/controllers/portal_usuario_controller.go @@ -8,6 +8,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/app" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" ) func PortalUsuariosIndex(c *fiber.Ctx) error { @@ -29,12 +30,14 @@ func LoadPortalUsuarios(c *fiber.Ctx) error { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } clientes, _, _ := models.GetAllClientes(200, 0, "") + portalRoles, _ := models.GetPortalRoles() return c.JSON(fiber.Map{ - "items": items, - "total": total, - "totalPages": int(math.Ceil(float64(total) / float64(limit))), - "page": page, - "clientes": clientes, + "items": items, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + "clientes": clientes, + "portalRoles": portalRoles, }) } @@ -44,6 +47,7 @@ func CreatePortalUsuario(c *fiber.Ctx) error { Email string `json:"email"` Password string `json:"password"` ClienteID *uint `json:"cliente_id"` + RoleID *uint `json:"role_id"` Rol string `json:"rol"` Notas string `json:"notas"` TelegramChatID string `json:"telegram_chat_id"` @@ -62,12 +66,23 @@ func CreatePortalUsuario(c *fiber.Ctx) error { if req.Rol == "" { req.Rol = "cliente" } + // Derivar Rol del tipo de rol seleccionado + rol := req.Rol + if req.RoleID != nil { + var role models.Roles + if err := app.Http.Database.DB.First(&role, *req.RoleID).Error; err == nil { + if role.EsPortalPartner { + rol = "partner" + } + } + } u := &models.PortalUser{ Nombre: req.Nombre, Email: strings.ToLower(strings.TrimSpace(req.Email)), Password: hashed, ClienteID: req.ClienteID, - Rol: req.Rol, + RoleID: req.RoleID, + Rol: rol, Activo: true, Notas: req.Notas, TelegramChatID: req.TelegramChatID, @@ -89,6 +104,7 @@ func UpdatePortalUsuario(c *fiber.Ctx) error { Email string `json:"email"` Password string `json:"password"` ClienteID *uint `json:"cliente_id"` + RoleID *uint `json:"role_id"` Rol string `json:"rol"` Activo bool `json:"activo"` Notas string `json:"notas"` @@ -98,11 +114,22 @@ func UpdatePortalUsuario(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": err.Error()}) } + // Derivar Rol del tipo de rol seleccionado + rol := req.Rol + if req.RoleID != nil { + var role models.Roles + if err := app.Http.Database.DB.First(&role, *req.RoleID).Error; err == nil { + if role.EsPortalPartner { + rol = "partner" + } + } + } u := &models.PortalUser{ Nombre: req.Nombre, Email: strings.ToLower(strings.TrimSpace(req.Email)), ClienteID: req.ClienteID, - Rol: req.Rol, + RoleID: req.RoleID, + Rol: rol, Activo: req.Activo, Notas: req.Notas, TelegramChatID: req.TelegramChatID, @@ -113,7 +140,7 @@ func UpdatePortalUsuario(c *fiber.Ctx) error { } // Actualizar password solo si se envió if strings.TrimSpace(req.Password) != "" { - hashed, err := app.Http.Hash.Create(req.Password) + hashed, err := app.Http.Hash.Create(req.Password) if err == nil { _ = models.UpdatePortalUserPassword(uint(id), hashed) } @@ -155,3 +182,29 @@ func RemovePortalAcceso(c *fiber.Ctx) error { } return c.JSON(fiber.Map{"ok": true}) } + +// SendPortalCredentials envía las credenciales de acceso al portal al correo del usuario. +func SendPortalCredentials(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + u, err := models.GetPortalUserByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "Usuario no encontrado"}) + } + + type Req struct { + Password string `json:"password"` + } + var req Req + _ = c.BodyParser(&req) + + password := req.Password + if password == "" { + password = "(la contraseña que configuraste)" + } + + services.SendPortalCredentialsEmail(u.Email, u.Nombre, password) + return c.JSON(fiber.Map{"ok": true, "message": "Correo enviado a " + u.Email}) +} diff --git a/rest/middlewares/portal_auth.go b/rest/middlewares/portal_auth.go index 696455e..bf41fc4 100644 --- a/rest/middlewares/portal_auth.go +++ b/rest/middlewares/portal_auth.go @@ -7,11 +7,13 @@ import ( ) // PortalAuth protege las rutas del portal de clientes. -// Si no hay sesión activa, redirige a /portal/login. +// Si no hay sesión válida, destruye la sesión obsoleta y redirige a /portal/login. func PortalAuth() fiber.Handler { return func(c *fiber.Ctx) error { user, err := auth.PortalUser(c) if err != nil || user == nil { + // Limpiar sesión inválida/obsoleta para romper posibles redirect loops + _ = auth.DestroyPortalSession(c) return c.Redirect("/portal/login") } c.Locals("portalUser", user) diff --git a/rest/routes/user.go b/rest/routes/user.go index b8d61a6..0dcf6de 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -254,6 +254,7 @@ func UserRoutes(app fiber.Router) { protected.Delete("/portal-usuarios/:id", controllers.DeletePortalUsuario) protected.Post("/portal-usuarios/:id/acceso", controllers.AddPortalAcceso) protected.Delete("/portal-usuarios/:id/acceso/:clienteID", controllers.RemovePortalAcceso) + protected.Post("/portal-usuarios/:id/send-credentials", controllers.SendPortalCredentials) protected.Get("/facturas", middlewares.MenuMiddleware, controllers.FacturasIndex) protected.Get("/loadfacturas", controllers.LoadFacturas)