This commit is contained in:
Lizandro Guarnizo
2026-05-16 13:44:32 -05:00
parent 85595e639e
commit 76a0c28b16
16 changed files with 225 additions and 725 deletions
+101 -176
View File
@@ -81,9 +81,8 @@ func Migrate() {
&models.ProyectoEntregable{},
&models.ProyectoTicket{},
&models.TicketMensaje{},
&models.Factura{}, // Sistema de notificaciones
&models.NotifEventoConfig{},
&models.SistemaNotificacion{}, ); err != nil {
&models.Factura{},
); err != nil {
log.Fatalf("Error during main migration: %v", err)
}
@@ -278,31 +277,6 @@ func SeedPlantillasBase() {
log.Println("[SEED] SeedPlantillasBase completado.")
}
// MigratePortal crea/actualiza las tablas del módulo Portal de Clientes, Proyectos,
// Facturas y Telegram. Es idempotente.
func MigratePortal() {
db := app.Http.Database.DB
if err := db.AutoMigrate(
&models.Roles{}, // agrega columnas es_portal_cliente / es_portal_partner si no existen
&models.TelegramConfig{},
&models.TelegramLog{},
&models.ClienteDocumento{},
&models.PortalUser{},
&models.PortalAcceso{},
&models.Proyecto{},
&models.ProyectoFase{},
&models.ProyectoAvance{},
&models.ProyectoEntregable{},
&models.ProyectoTicket{},
&models.TicketMensaje{},
&models.Factura{},
); err != nil {
log.Printf("[MIGRATE] Error en MigratePortal: %v", err)
} else {
log.Println("[MIGRATE] Tablas de Portal OK")
}
}
// MigrateRenovaciones crea/actualiza las tablas del módulo de Renovaciones.
// Es idempotente: GORM AutoMigrate solo añade columnas/tablas nuevas, nunca las borra.
func MigrateRenovaciones() {
@@ -737,7 +711,27 @@ func SeedSaas() {
log.Println("[SEED] Seed de SaaS completado.")
}
// SeedTelegram agrega el submódulo "Telegram" al módulo "Integraciones". Es idempotente.
// MigratePortal ejecuta AutoMigrate para los modelos del portal de clientes.
func MigratePortal() {
db := app.Http.Database.DB
if err := db.AutoMigrate(
&models.Proyecto{},
&models.ProyectoFase{},
&models.ProyectoAvance{},
&models.ProyectoEntregable{},
&models.ProyectoTicket{},
&models.TicketMensaje{},
&models.PortalUser{},
&models.PortalAcceso{},
&models.Factura{},
); err != nil {
log.Printf("[MIGRATE] Error en MigratePortal: %v", err)
} else {
log.Println("[MIGRATE] MigratePortal completado.")
}
}
// SeedTelegram agrega el submódulo de Telegram al módulo "Integraciones". Es idempotente.
func SeedTelegram() {
db := app.Http.Database.DB
@@ -747,160 +741,23 @@ func SeedTelegram() {
return
}
entries := []struct{ title, desc, url string }{
{"Telegram", "Notificaciones y alertas por Telegram", "/app/telegram"},
}
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
} else {
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
}
insertados = append(insertados, sub)
}
var roles []models.Roles
if err := db.Find(&roles).Error; err != nil {
log.Printf("[SEED] Error obteniendo roles: %v", err)
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando Telegram al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Telegram asignado al rol '%s'", rol.Name)
}
}
log.Println("[SEED] Seed de Telegram completado.")
}
// SeedPortalClientes crea el módulo "Portal de Clientes" con sus submódulos. Es idempotente.
func SeedPortalClientes() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Portal de Clientes").First(&modulo).Error; err != nil {
modulo = models.Modules{
Title: "Portal de Clientes",
Description: "Portal de clientes: proyectos, facturas y accesos",
ModifiedAt: time.Now(),
}
if err := db.Create(&modulo).Error; err != nil {
log.Printf("[SEED] Error creando módulo Portal de Clientes: %v", err)
return
}
log.Printf("[SEED] Módulo 'Portal de Clientes' creado con ID %d", modulo.ID)
} else {
log.Printf("[SEED] Módulo 'Portal de Clientes' ya existe (ID %d)", modulo.ID)
}
entries := []struct{ title, desc, url string }{
{"Proyectos", "Gestión de proyectos y avances del portal", "/app/proyectos"},
{"Facturas", "Gestión de facturas y pagos por cliente", "/app/facturas"},
{"Portal Usuarios", "Usuarios con acceso al portal de clientes", "/app/portal-usuarios"},
{"Tickets", "Tickets de soporte abiertos por clientes del portal", "/app/tickets"},
{"Config. Notificaciones", "Configurar canales de notificación por evento", "/app/notif-config"},
}
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
} else {
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
}
insertados = append(insertados, sub)
}
var roles []models.Roles
if err := db.Find(&roles).Error; err != nil {
log.Printf("[SEED] Error obteniendo roles: %v", err)
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando submódulos Portal al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Submódulos de Portal de Clientes asignados al rol '%s'", rol.Name)
}
}
log.Println("[SEED] Seed de Portal de Clientes completado.")
}
// SeedNotifDefaults crea la configuración de notificaciones por defecto. Es idempotente.
func SeedNotifDefaults() {
db := app.Http.Database.DB
defaults := []models.NotifEventoConfig{
{Evento: "ticket_nuevo", Destinatario: "admin", CanalEmail: true, CanalTelegram: false, CanalSistema: true, Descripcion: "Admin recibe cuando el cliente abre un ticket"},
{Evento: "ticket_respuesta_cliente", Destinatario: "admin", CanalEmail: true, CanalTelegram: false, CanalSistema: true, Descripcion: "Admin recibe cuando el cliente responde un ticket"},
{Evento: "ticket_respuesta_admin", Destinatario: "portal_user", CanalEmail: true, CanalTelegram: false, CanalSistema: true, Descripcion: "Cliente recibe cuando el admin responde su ticket"},
{Evento: "factura_subida", Destinatario: "portal_user", CanalEmail: true, CanalTelegram: false, CanalSistema: true, Descripcion: "Cliente recibe cuando el admin sube el PDF de una factura"},
}
for _, cfg := range defaults {
var existing models.NotifEventoConfig
if err := db.Where("evento = ? AND destinatario = ?", cfg.Evento, cfg.Destinatario).First(&existing).Error; err != nil {
if err := db.Create(&cfg).Error; err != nil {
log.Printf("[SEED] Error creando notif config %s/%s: %v", cfg.Evento, cfg.Destinatario, err)
} else {
log.Printf("[SEED] Notif config creada: %s → %s", cfg.Evento, cfg.Destinatario)
}
}
}
log.Println("[SEED] Seed de notificaciones completado.")
}
// SeedShield agrega el submódulo "Shield" al módulo "Integraciones". Es idempotente.
func SeedShield() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Integraciones").First(&modulo).Error; err != nil {
log.Printf("[SEED] Módulo 'Integraciones' no encontrado para SeedShield: %v", err)
return
}
url := "/app/telegram"
var sub models.Submodules
if err := db.Where("url = ?", "/app/shield").First(&sub).Error; err != nil {
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: "Shield",
Description: "Administración de la API de seguridad USITE Shield",
Url: "/app/shield",
Title: "Telegram",
Description: "Configuración de bots de Telegram para notificaciones",
Url: url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo Shield: %v", err)
if err2 := db.Create(&sub).Error; err2 != nil {
log.Printf("[SEED] Error creando submódulo Telegram: %v", err2)
return
}
log.Printf("[SEED] Submódulo 'Shield' creado (ID %d)", sub.ID)
log.Printf("[SEED] Submódulo 'Telegram' creado (ID %d)", sub.ID)
} else {
log.Printf("[SEED] Submódulo 'Shield' ya existe (ID %d)", sub.ID)
log.Printf("[SEED] Submódulo 'Telegram' ya existe (ID %d)", sub.ID)
}
var roles []models.Roles
@@ -910,5 +767,73 @@ func SeedShield() {
for _, rol := range roles {
db.Model(&rol).Association("Submodules").Append(&sub)
}
log.Println("[SEED] SeedShield completado.")
log.Println("[SEED] Seed de Telegram completado.")
}
// SeedPortalClientes agrega el módulo "Portal de Clientes" con sus submódulos. Es idempotente.
func SeedPortalClientes() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Portal de Clientes").First(&modulo).Error; err != nil {
modulo = models.Modules{
Title: "Portal de Clientes",
Description: "Gestión del portal de acceso para clientes",
ModifiedAt: time.Now(),
}
if err2 := db.Create(&modulo).Error; err2 != nil {
log.Printf("[SEED] Error creando módulo 'Portal de Clientes': %v", err2)
return
}
log.Printf("[SEED] Módulo 'Portal de Clientes' creado (ID %d)", modulo.ID)
} else {
log.Printf("[SEED] Módulo 'Portal de Clientes' ya existe (ID %d)", modulo.ID)
}
entries := []struct{ title, desc, url string }{
{"Proyectos", "Gestión de proyectos y roadmap para clientes", "/app/proyectos"},
{"Facturas", "Gestión de facturas y cobros del portal", "/app/facturas"},
{"Portal Usuarios", "Gestión de usuarios del portal de clientes", "/app/portal-usuarios"},
}
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err2 := db.Create(&sub).Error; err2 != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err2)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
} else {
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
}
insertados = append(insertados, sub)
}
var roles []models.Roles
if err := db.Find(&roles).Error; err != nil {
return
}
for _, rol := range roles {
db.Model(&rol).Association("Submodules").Append(&insertados)
}
log.Println("[SEED] Seed de Portal de Clientes completado.")
}
// SeedNotifDefaults es un stub idempotente para configuraciones de notificaciones por defecto.
func SeedNotifDefaults() {
log.Println("[SEED] SeedNotifDefaults: sin entradas por defecto definidas aún.")
}
// SeedShield es un stub idempotente para la configuración de Shield.
func SeedShield() {
log.Println("[SEED] SeedShield: sin entradas por defecto definidas aún.")
}
+3 -23
View File
@@ -2,7 +2,6 @@ package auth
import (
"errors"
"log"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/app"
@@ -15,20 +14,11 @@ const portalSessionKey = "portal_user_id"
func PortalUser(c *fiber.Ctx) (*models.PortalUser, error) {
store := app.Http.Session.Get(c)
raw := store.Get(portalSessionKey)
log.Printf("[PORTAL SESSION] GET %s → cookie=%q raw=%v", c.Path(), c.Cookies("session_id"), raw)
if raw == nil {
return nil, errors.New("no portal session")
}
var id uint
switch v := raw.(type) {
case uint:
id = v
case int64:
id = uint(v)
case float64:
id = uint(v)
default:
log.Printf("[PORTAL SESSION] type assertion failed: %T", raw)
id, ok := raw.(uint)
if !ok {
return nil, errors.New("invalid portal session")
}
u, err := models.GetPortalUserByID(id)
@@ -45,18 +35,8 @@ func PortalUser(c *fiber.Ctx) (*models.PortalUser, error) {
// SetPortalSession guarda el portal_user_id en la sesión.
func SetPortalSession(c *fiber.Ctx, userID uint) error {
store := app.Http.Session.Get(c)
// Regenerar ID de sesión para evitar session fixation
if err := store.Regenerate(); err != nil {
log.Printf("[PORTAL SESSION] Regenerate error: %v", err)
return err
}
store.Set(portalSessionKey, userID)
if err := store.Save(); err != nil {
log.Printf("[PORTAL SESSION] Save error: %v", err)
return err
}
log.Printf("[PORTAL SESSION] SET userID=%d cookie=%q", userID, c.Cookies("session_id"))
return nil
return store.Save()
}
// DestroyPortalSession elimina la sesión del portal.
+49 -36
View File
@@ -14,20 +14,15 @@ import (
// - Rol "partner": accede a los proyectos de todos sus ClienteIDs via PortalAccesos.
type PortalUser struct {
gorm.Model
Nombre string `json:"nombre" gorm:"column:nombre;not null"`
Email string `json:"email" gorm:"column:email;uniqueIndex;not null"`
Password string `json:"-" gorm:"column:password;type:text"`
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
Cliente *Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
Rol string `json:"rol" gorm:"column:rol;default:'cliente'"`
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"`
Nombre string `json:"nombre" gorm:"column:nombre;not null"`
Email string `json:"email" gorm:"column:email;uniqueIndex;not null"`
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"`
PortalAccesos []PortalAcceso `json:"portal_accesos" gorm:"foreignKey:PortalUserID"`
}
func (PortalUser) TableName() string { return "portal_users" }
@@ -48,7 +43,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").Preload("Role")
db := app.Http.Database.DB.Model(&PortalUser{}).Preload("Cliente").Preload("PortalAccesos.Cliente")
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
@@ -60,7 +55,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").Preload("Role").First(&item, id).Error
err := app.Http.Database.DB.Preload("Cliente").Preload("PortalAccesos.Cliente").First(&item, id).Error
return &item, err
}
@@ -80,12 +75,8 @@ func UpdatePortalUser(u *PortalUser) error {
"email": u.Email,
"cliente_id": u.ClienteID,
"rol": u.Rol,
"role_id": u.RoleID,
"activo": u.Activo,
"notas": u.Notas,
"telegram_chat_id": u.TelegramChatID,
"telefono": u.Telefono,
"pais": u.Pais,
"activo": u.Activo,
"notas": u.Notas,
}).Error
}
@@ -101,6 +92,41 @@ func DeletePortalUser(id uint) error {
return app.Http.Database.DB.Delete(&PortalUser{}, id).Error
}
// GetPortalUsersByClienteID devuelve todos los portal_users activos asociados a un cliente,
// ya sea directamente (rol cliente) o mediante PortalAcceso (rol partner).
func GetPortalUsersByClienteID(clienteID uint) ([]PortalUser, error) {
var result []PortalUser
db := app.Http.Database.DB
// Usuarios directos del cliente
var directos []PortalUser
if err := db.Where("cliente_id = ? AND activo = true", clienteID).Find(&directos).Error; err != nil {
return nil, err
}
result = append(result, directos...)
// Usuarios partner con acceso al cliente via PortalAcceso
var accesos []PortalAcceso
if err := db.Where("cliente_id = ?", clienteID).Find(&accesos).Error; err != nil {
return nil, err
}
seen := make(map[uint]bool)
for _, d := range directos {
seen[d.ID] = true
}
for _, a := range accesos {
if seen[a.PortalUserID] {
continue
}
var u PortalUser
if err := db.Where("id = ? AND activo = true", a.PortalUserID).First(&u).Error; err == nil {
result = append(result, u)
seen[u.ID] = true
}
}
return result, nil
}
func AddPortalAcceso(portalUserID, clienteID uint) error {
// Evitar duplicados
var existing PortalAcceso
@@ -121,11 +147,7 @@ func RemovePortalAcceso(portalUserID, clienteID uint) error {
// GetClienteIDsForPortalUser devuelve todos los clienteIDs accesibles para un portal user.
func GetClienteIDsForPortalUser(u *PortalUser) []uint {
isPartner := u.Rol == "partner"
if u.Role != nil {
isPartner = u.Role.EsPortalPartner
}
if isPartner {
if u.Rol == "partner" {
ids := make([]uint, 0, len(u.PortalAccesos))
for _, a := range u.PortalAccesos {
ids = append(ids, a.ClienteID)
@@ -153,12 +175,3 @@ func CheckPortalLogin(email, password string) (*PortalUser, error) {
}
return u, nil
}
// GetPortalUsersByClienteID devuelve todos los portal users activos de un cliente.
func GetPortalUsersByClienteID(clienteID uint) ([]PortalUser, error) {
var users []PortalUser
err := app.Http.Database.DB.
Where("cliente_id = ? AND activo = true", clienteID).
Find(&users).Error
return users, err
}
+1 -4
View File
@@ -124,11 +124,8 @@ 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.Preload("Cliente").Where("cliente_id IN ?", clienteIDs).Order("created_at DESC").Find(&items).Error
err := app.Http.Database.DB.Where("cliente_id IN ?", clienteIDs).Order("created_at DESC").Find(&items).Error
return items, err
}
+1 -12
View File
@@ -9,8 +9,7 @@ import (
type ProyectoTicket struct {
gorm.Model
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
Proyecto Proyecto `json:"proyecto" gorm:"foreignKey:ProyectoID"`
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
PortalUserID uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
Titulo string `json:"titulo" gorm:"column:titulo"`
@@ -68,13 +67,3 @@ func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) {
Preload("Mensajes").Order("created_at DESC").Find(&items).Error
return items, err
}
func GetAllTickets(estado string) ([]ProyectoTicket, error) {
var items []ProyectoTicket
db := app.Http.Database.DB.Preload("Proyecto").Preload("Mensajes").Order("created_at DESC")
if estado != "" && estado != "todos" {
db = db.Where("estado = ?", estado)
}
err := db.Find(&items).Error
return items, err
}
+31 -143
View File
@@ -8,118 +8,61 @@ import (
)
// ─── DispatchTicketNuevo ──────────────────────────────────────────────────────
// Llamar cuando el portal user crea un nuevo ticket.
// Notifica al ADMIN según su configuración de canales.
// Notifica al admin cuando un portal user crea un ticket.
func DispatchTicketNuevo(ticket *models.ProyectoTicket, portalUser *models.PortalUser, proyectoNombre string) {
cfg := models.GetNotifConfig("ticket_nuevo", "admin")
if cfg == nil {
if ticket == nil || portalUser == nil {
return
}
ticketURL := "/app/tickets"
titulo := fmt.Sprintf("Nuevo ticket: %s", ticket.Titulo)
cuerpo := fmt.Sprintf("Cliente %s abrió: %s", ticket.AutorNombre, ticket.Titulo)
if cfg.CanalSistema {
_ = models.CreateSistemaNotif(&models.SistemaNotificacion{
TipoUsuario: "admin",
UsuarioID: 0,
Titulo: titulo,
Cuerpo: cuerpo,
Url: ticketURL,
Icono: "🎫",
})
}
if cfg.CanalEmail {
if adminEmail := getAdminEmail(); adminEmail != "" {
SendTicketNuevoAdmin(adminEmail, proyectoNombre, ticket.AutorNombre, ticket.Titulo, ticket.Descripcion, ticketURL)
}
}
if cfg.CanalTelegram {
msg := fmt.Sprintf("🎫 <b>Nuevo ticket</b>\nProyecto: <b>%s</b>\nCliente: %s\nTítulo: <b>%s</b>\n\n%s\n\n🔗 %s",
proyectoNombre, ticket.AutorNombre, ticket.Titulo, ticket.Descripcion, ticketURL)
sendTelegramAdmin(msg)
}
msg := fmt.Sprintf("🎫 <b>Nuevo ticket</b>\nProyecto: <b>%s</b>\nCliente: %s\nTítulo: <b>%s</b>\n\n%s",
proyectoNombre, ticket.AutorNombre, ticket.Titulo, ticket.Descripcion)
sendTelegramAdmin(msg)
}
// ─── DispatchTicketRespuestaCliente ──────────────────────────────────────────
// Llamar cuando el portal user responde un ticket.
// Notifica al ADMIN según su configuración de canales.
// Notifica al admin cuando el portal user responde un ticket.
func DispatchTicketRespuestaCliente(ticket *models.ProyectoTicket, contenido string, proyectoNombre string) {
cfg := models.GetNotifConfig("ticket_respuesta_cliente", "admin")
if cfg == nil {
if ticket == nil {
return
}
ticketURL := "/app/tickets"
titulo := fmt.Sprintf("Respuesta de cliente: %s", ticket.Titulo)
cuerpo := fmt.Sprintf("%s respondió: %s", ticket.AutorNombre, contenido)
if cfg.CanalSistema {
_ = models.CreateSistemaNotif(&models.SistemaNotificacion{
TipoUsuario: "admin",
UsuarioID: 0,
Titulo: titulo,
Cuerpo: cuerpo,
Url: ticketURL,
Icono: "💬",
})
}
if cfg.CanalEmail {
if adminEmail := getAdminEmail(); adminEmail != "" {
SendTicketRespuestaAdmin(adminEmail, proyectoNombre, ticket.AutorNombre, ticket.Titulo, contenido, ticketURL)
}
}
if cfg.CanalTelegram {
msg := fmt.Sprintf("💬 <b>Respuesta de cliente</b>\nProyecto: <b>%s</b>\nCliente: %s\n\n%s\n\n🔗 %s",
proyectoNombre, ticket.AutorNombre, contenido, ticketURL)
sendTelegramAdmin(msg)
}
msg := fmt.Sprintf("💬 <b>Respuesta de cliente</b>\nProyecto: <b>%s</b>\nCliente: %s\n\n%s",
proyectoNombre, ticket.AutorNombre, contenido)
sendTelegramAdmin(msg)
}
// ─── DispatchTicketRespuestaAdmin ─────────────────────────────────────────────
// Llamar cuando el admin responde un ticket.
// Notifica al PORTAL USER según su configuración de canales.
// Notifica al portal user cuando el admin responde un ticket.
func DispatchTicketRespuestaAdmin(ticket *models.ProyectoTicket, contenido string, proyectoNombre string) {
cfg := models.GetNotifConfig("ticket_respuesta_admin", "portal_user")
if cfg == nil {
if ticket == nil {
return
}
// Obtener portal user para saber email y telegram
portalUser, err := models.GetPortalUserByID(ticket.PortalUserID)
if err != nil || portalUser == nil {
log.Printf("[Notif] PortalUser %d no encontrado: %v", ticket.PortalUserID, err)
return
}
portalURL := fmt.Sprintf("/portal/proyecto/%s", ticket.Proyecto.Slug)
titulo := fmt.Sprintf("Respuesta en tu ticket: %s", ticket.Titulo)
cuerpo := fmt.Sprintf("El equipo respondió: %s", contenido)
if cfg.CanalSistema {
_ = models.CreateSistemaNotif(&models.SistemaNotificacion{
TipoUsuario: "portal_user",
UsuarioID: portalUser.ID,
Titulo: titulo,
Cuerpo: cuerpo,
Url: portalURL,
Icono: "💬",
})
}
if cfg.CanalEmail && portalUser.Email != "" {
SendTicketRespuestaPortalUser(portalUser.Email, portalUser.Nombre, proyectoNombre, ticket.Titulo, contenido, portalURL)
}
if cfg.CanalTelegram && portalUser.TelegramChatID != "" {
ts := NewTelegramService()
msg := fmt.Sprintf("💬 <b>El equipo respondió tu ticket</b>\nProyecto: <b>%s</b>\nTicket: <b>%s</b>\n\n%s\n\n🔗 %s",
proyectoNombre, ticket.Titulo, contenido, portalURL)
if err := ts.SendMessageWithToken(portalUser.TelegramChatID, msg, getAdminBotToken()); err != nil {
log.Printf("[Notif] Error telegram portal_user %d: %v", portalUser.ID, err)
}
if portalUser.Email == "" {
return
}
// TODO: enviar email al portal user cuando mail_service implemente SendTicketRespuestaPortalUser
_ = fmt.Sprintf("/portal/dashboard")
}
// ─── helpers internos ─────────────────────────────────────────────────────────
// ─── DispatchFacturaSubida ────────────────────────────────────────────────────
// Notifica a los portal_users del cliente cuando se sube una factura.
func DispatchFacturaSubida(factura *models.Factura) {
if factura == nil || factura.ClienteID == 0 {
return
}
portalUsers, err := models.GetPortalUsersByClienteID(factura.ClienteID)
if err != nil || len(portalUsers) == 0 {
return
}
log.Printf("[Notif] DispatchFacturaSubida factura=%d portal_users=%d (pendiente de implementar)",
factura.ID, len(portalUsers))
}
// ─── helpers ─────────────────────────────────────────────────────────────────
// getAdminEmail devuelve el from_address del SMTP activo como email del admin.
func getAdminEmail() string {
cfg, err := models.GetSmtpConfig()
if err != nil || cfg == nil {
@@ -128,7 +71,6 @@ func getAdminEmail() string {
return cfg.FromAddress
}
// getAdminBotToken devuelve el token del primer TelegramConfig activo.
func getAdminBotToken() string {
configs, err := models.GetAllTelegramConfigs()
if err != nil {
@@ -142,7 +84,6 @@ func getAdminBotToken() string {
return ""
}
// sendTelegramAdmin envía un mensaje usando el primer TelegramConfig activo.
func sendTelegramAdmin(mensaje string) {
configs, err := models.GetAllTelegramConfigs()
if err != nil {
@@ -159,56 +100,3 @@ func sendTelegramAdmin(mensaje string) {
}
}
}
// ─── DispatchFacturaSubida ────────────────────────────────────────────────────
// Llamar cuando el admin sube el PDF de una factura.
// Notifica a todos los portal_users activos del cliente.
func DispatchFacturaSubida(factura *models.Factura) {
cfg := models.GetNotifConfig("factura_subida", "portal_user")
if cfg == nil {
return
}
if factura.ClienteID == 0 {
return
}
portalUsers, err := models.GetPortalUsersByClienteID(factura.ClienteID)
if err != nil || len(portalUsers) == 0 {
return
}
clienteNombre := ""
if factura.Cliente.Nombre != "" {
clienteNombre = factura.Cliente.Nombre
} else if factura.Cliente.Empresa != "" {
clienteNombre = factura.Cliente.Empresa
}
for _, u := range portalUsers {
u := u // capture
portalURL := "/portal/dashboard"
titulo := fmt.Sprintf("Nueva factura disponible: %s", factura.Numero)
cuerpo := fmt.Sprintf("Factura %s por %s %s", factura.Numero, factura.Moneda, fmt.Sprintf("%.2f", factura.Monto))
if cfg.CanalSistema {
_ = models.CreateSistemaNotif(&models.SistemaNotificacion{
TipoUsuario: "portal_user",
UsuarioID: u.ID,
Titulo: titulo,
Cuerpo: cuerpo,
Url: portalURL,
Icono: "🧾",
})
}
if cfg.CanalEmail && u.Email != "" {
go SendFacturaSubidaPortalUser(u.Email, u.Nombre, clienteNombre, factura.Numero, factura.Monto, factura.Moneda, portalURL)
}
if cfg.CanalTelegram && u.TelegramChatID != "" {
ts := NewTelegramService()
msg := fmt.Sprintf("🧾 <b>Nueva factura disponible</b>\nNúmero: <b>%s</b>\nMonto: %s %.2f\n\n🔗 %s",
factura.Numero, factura.Moneda, factura.Monto, portalURL)
if err := ts.SendMessageWithToken(u.TelegramChatID, msg, getAdminBotToken()); err != nil {
log.Printf("[Notif] Error telegram portal_user %d: %v", u.ID, err)
}
}
}
log.Printf("[Notif] DispatchFacturaSubida factura=%d clientes notificados=%d", factura.ID, len(portalUsers))
}
+4 -73
View File
@@ -20,84 +20,15 @@
<body class="min-h-screen flex flex-col">
<!-- Navbar -->
<header class="bg-white border-b border-slate-200 sticky top-0 z-30"
x-data="{
bellOpen: false,
notifs: [],
unread: 0,
async loadNotifs() {
try {
const r = await axios.get('/portal/mis-notifs?no_leidas=0');
this.notifs = r.data.items || [];
this.unread = r.data.unread || 0;
} catch {}
},
async markRead(id) {
await axios.put('/portal/mis-notifs/' + id + '/leida');
const n = this.notifs.find(x => x.ID === id);
if (n) { n.leida = true; this.unread = Math.max(0, this.unread - 1); }
},
async markAll() {
await axios.post('/portal/mis-notifs/marcar-todas');
this.notifs.forEach(n => n.leida = true);
this.unread = 0;
}
}"
x-init="loadNotifs(); setInterval(() => loadNotifs(), 30000)">
<header class="bg-white border-b border-slate-200 sticky top-0 z-30">
<div class="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
<!-- Logo -->
<a href="/portal/dashboard" class="flex items-center gap-2 font-bold text-slate-800 text-lg">
<span class="w-7 h-7 rounded-full flex items-center justify-content:center text-white text-xs font-bold flex items-center justify-center" style="background:#8eb02f">U</span>
<span class="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-bold" style="background:#8eb02f">U</span>
<span>Portal de Clientes</span>
</a>
<!-- Right: bell + user -->
<div class="flex items-center gap-3">
<!-- 🔔 Bell -->
<div class="relative">
<button @click="bellOpen = !bellOpen; if(bellOpen) loadNotifs()"
class="relative p-2 rounded-xl hover:bg-slate-100 transition-colors focus:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"/>
</svg>
<span x-show="unread > 0"
class="absolute -top-0.5 -right-0.5 h-4 w-4 flex items-center justify-center rounded-full text-white text-[10px] font-bold"
style="background:#8eb02f"
x-text="unread > 9 ? '9+' : unread">
</span>
</button>
<!-- Dropdown -->
<div x-show="bellOpen" @click.outside="bellOpen = false"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95 -translate-y-1"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100 translate-y-0"
x-transition:leave-end="opacity-0 scale-95 -translate-y-1"
class="absolute right-0 top-12 w-72 bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden z-50">
<div class="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
<span class="text-sm font-semibold text-slate-800">Notificaciones</span>
<button x-show="unread > 0" @click="markAll()" class="text-xs text-slate-400 hover:text-slate-600">Marcar todas leídas</button>
</div>
<div class="max-h-64 overflow-y-auto divide-y divide-slate-100">
<template x-if="notifs.length === 0">
<div class="px-4 py-6 text-center text-slate-400 text-sm">Sin notificaciones</div>
</template>
<template x-for="n in notifs" :key="n.ID">
<a :href="n.url || '#'" @click="if(!n.leida) markRead(n.ID)"
class="flex items-start gap-3 px-4 py-3 hover:bg-slate-50 transition-colors"
:class="!n.leida ? 'bg-emerald-50/50' : ''">
<span class="text-lg leading-none mt-0.5" x-text="n.icono || '🔔'"></span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-slate-800 truncate" x-text="n.titulo"></p>
<p class="text-xs text-slate-500 truncate mt-0.5" x-text="n.cuerpo"></p>
</div>
<div x-show="!n.leida" class="w-2 h-2 rounded-full flex-shrink-0 mt-1.5" style="background:#8eb02f"></div>
</a>
</template>
</div>
</div>
</div>
<!-- User name -->
<!-- User -->
<div class="flex items-center gap-4">
<span class="text-sm text-slate-600 hidden sm:block">
{{ if .portalUser }}{{ .portalUser.Nombre }}{{ end }}
</span>
+13 -63
View File
@@ -1,6 +1,6 @@
<div x-data="portalUsuariosApp()" x-init="init()" class="p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800">Usuarios del Portal</h1>
<p class="text-sm text-slate-500 mt-1">Accesos del portal de clientes</p>
@@ -11,15 +11,6 @@
</button>
</div>
<!-- Banner URL del portal -->
<div class="flex items-center gap-3 bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 mb-6 text-sm">
<svg class="w-4 h-4 text-blue-500 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
<span class="text-blue-700">Los usuarios inician sesión en:</span>
<a href="/portal/login" target="_blank" class="font-semibold text-blue-600 hover:underline">/portal/login</a>
<button @click="navigator.clipboard.writeText(window.location.origin+'/portal/login').then(()=>alert('URL copiada'))"
class="ml-auto text-xs text-blue-500 hover:text-blue-700 border border-blue-300 rounded px-2 py-0.5">Copiar enlace</button>
</div>
<!-- Tabla -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-x-auto">
<table class="w-full text-sm">
@@ -42,13 +33,13 @@
<td class="px-4 py-3 font-medium text-slate-800" x-text="u.nombre"></td>
<td class="px-4 py-3 text-slate-600" x-text="u.email"></td>
<td class="px-4 py-3">
<span class="badge" :class="(u.role?.es_portal_partner || u.rol==='partner') ? 'badge-blue' : 'badge-green'" x-text="u.role?.name || u.rol"></span>
<span class="badge" :class="u.rol==='partner' ? 'badge-blue' : 'badge-green'" x-text="u.rol"></span>
</td>
<td class="px-4 py-3 text-slate-500 text-xs">
<template x-if="!(u.role?.es_portal_partner || u.rol==='partner') && u.cliente">
<template x-if="u.rol==='cliente' && u.cliente">
<span x-text="u.cliente?.empresa || u.cliente?.nombre"></span>
</template>
<template x-if="u.role?.es_portal_partner || u.rol==='partner'">
<template x-if="u.rol==='partner'">
<div class="flex flex-wrap gap-1">
<template x-for="acc in (u.portal_accesos||[])" :key="acc.ID">
<span class="bg-slate-100 px-2 py-0.5 rounded text-xs flex items-center gap-1">
@@ -67,9 +58,6 @@
<button @click="openEdit(u)" class="btn-icon text-yellow-500" title="Editar">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button @click="openSendCredentials(u)" class="btn-icon text-blue-500" title="Enviar credenciales">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
</button>
<button @click="confirmDelete(u)" class="btn-icon text-red-500" title="Eliminar">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
@@ -94,16 +82,12 @@
</div>
<div>
<label class="label">Rol</label>
<select x-model.number="form.role_id" class="input-field w-full">
<option value="">Sin rol asignado</option>
<template x-for="r in portalRoles" :key="r.ID">
<option :value="r.ID">
<span x-text="r.name + (r.es_portal_partner ? ' (Partner)' : ' (Cliente)')"></span>
</option>
</template>
<select x-model="form.rol" class="input-field w-full">
<option value="cliente">Cliente</option>
<option value="partner">Partner</option>
</select>
</div>
<div x-show="portalRoles.find(r => r.ID === form.role_id)?.es_portal_cliente">
<div x-show="form.rol==='cliente'">
<label class="label">Cliente asignado</label>
<select x-model.number="form.cliente_id" class="input-field w-full">
<option value="">Sin asignar</option>
@@ -157,34 +141,16 @@
</div>
</div>
<!-- Modal enviar credenciales -->
<div x-show="showCredModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div @click.outside="showCredModal=false" class="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6">
<h2 class="text-lg font-bold mb-1">Enviar credenciales</h2>
<p class="text-slate-500 text-sm mb-4">Se enviará el correo con los datos de acceso al portal a <strong x-text="credEmail"></strong>.</p>
<div class="mb-4">
<label class="label">Contraseña (opcional — se muestra en el correo)</label>
<input x-model="credPassword" type="text" class="input-field w-full" placeholder="Dejar vacío para omitir la contraseña">
</div>
<p x-show="credMsg" x-text="credMsg" class="text-green-600 text-sm mb-3"></p>
<div class="flex justify-end gap-3">
<button @click="showCredModal=false" class="btn-secondary">Cancelar</button>
<button @click="doSendCredentials()" :disabled="saving" class="btn-primary" x-text="saving?'Enviando...':'Enviar correo'"></button>
</div>
</div>
</div>
</div>
<script>
function portalUsuariosApp() {
return {
items: [], clientes: [], portalRoles: [], loading: false, saving: false,
showModal: false, showDelete: false, showAccesoModal: false, showCredModal: false,
items: [], clientes: [], loading: false, saving: false,
showModal: false, showDelete: false, showAccesoModal: false,
editId: null, deleteId: null, error: '',
accesoUserId: null, accesoClienteId: '',
credUserId: null, credEmail: '', credPassword: '', credMsg: '',
form: { nombre:'', email:'', password:'', role_id:'', cliente_id:'', activo:true, notas:'' },
form: { nombre:'', email:'', password:'', rol:'cliente', cliente_id:'', activo:true, notas:'' },
async init() { await this.load(); },
@@ -194,25 +160,23 @@ function portalUsuariosApp() {
const r = await axios.get('/app/loadportalusuarios');
this.items = r.data.items || [];
this.clientes = r.data.clientes || [];
this.portalRoles = r.data.portalRoles || [];
} finally { this.loading = false; }
},
openCreate() {
this.editId=null; this.error='';
this.form={nombre:'',email:'',password:'',role_id:'',cliente_id:'',activo:true,notas:''};
this.form={nombre:'',email:'',password:'',rol:'cliente',cliente_id:'',activo:true,notas:''};
this.showModal=true;
},
openEdit(u) {
this.editId=u.ID; this.error='';
this.form={nombre:u.nombre,email:u.email,password:'',role_id:u.role_id||'',cliente_id:u.cliente_id||'',activo:u.activo,notas:u.notas||''};
this.form={nombre:u.nombre,email:u.email,password:'',rol:u.rol,cliente_id:u.cliente_id||'',activo:u.activo,notas:u.notas||''};
this.showModal=true;
},
async save() {
this.saving=true; this.error='';
const payload={...this.form};
if(payload.cliente_id==='') payload.cliente_id=null;
if(payload.role_id==='') payload.role_id=null;
try {
if(this.editId) await axios.put(`/app/portal-usuarios/${this.editId}`, payload);
else await axios.post('/app/portal-usuarios', payload);
@@ -237,20 +201,6 @@ function portalUsuariosApp() {
await axios.delete(`/app/portal-usuarios/${u.ID}/acceso/${clienteId}`);
await this.load();
},
openSendCredentials(u) {
this.credUserId=u.ID; this.credEmail=u.email; this.credPassword=''; this.credMsg='';
this.showCredModal=true;
},
async doSendCredentials() {
this.saving=true; this.credMsg='';
try {
const r = await axios.post(`/app/portal-usuarios/${this.credUserId}/send-credentials`, {password: this.credPassword});
this.credMsg = r.data.message || 'Correo enviado';
setTimeout(()=>{ this.showCredModal=false; }, 1800);
} catch(e) { this.credMsg = e.response?.data?.error || 'Error al enviar'; }
finally { this.saving=false; }
},
}
}
</script>
+2 -2
View File
@@ -8,10 +8,10 @@
<span class="w-4 h-4 rounded-full border" :style="`background:{{ .proyecto.Color }}`"></span>
<div>
<h1 class="text-xl font-bold text-slate-800">{{ .proyecto.Nombre }}</h1>
<p class="text-xs text-slate-400">{{ if .proyecto.Cliente }}{{ .proyecto.Cliente.RazonSocial }}{{ end }} &middot; slug: <code>{{ .proyecto.Slug }}</code></p>
<p class="text-xs text-slate-400">{{ if .proyecto.Cliente.Empresa }}{{ .proyecto.Cliente.Empresa }}{{ else if .proyecto.Cliente.Nombre }}{{ .proyecto.Cliente.Nombre }}{{ end }} &middot; slug: <code>{{ .proyecto.Slug }}</code></p>
</div>
<div class="ml-auto flex items-center gap-3">
<span class="badge" :class="`badge-${estadoColor('{{ .proyecto.Estado }}')}`">{{ .proyecto.Estado }}</span>
{{ if eq .proyecto.Estado "activo" }}<span class="badge badge-green">Activo</span>{{ else if eq .proyecto.Estado "pausado" }}<span class="badge badge-yellow">Pausado</span>{{ else if eq .proyecto.Estado "completado" }}<span class="badge badge-slate">Completado</span>{{ else }}<span class="badge badge-slate">{{ .proyecto.Estado }}</span>{{ end }}
<span class="text-sm text-slate-500">
<strong>{{ .proyecto.Progreso }}%</strong> completado
</span>
-5
View File
@@ -11,7 +11,6 @@ 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 {
@@ -186,10 +185,6 @@ 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})
}
+2 -52
View File
@@ -2,23 +2,19 @@ package controllers
import (
"fmt"
"log"
"net/url"
"strings"
"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 {
// Usar PortalUser (validación completa) en vez de IsPortalLoggedIn (solo key)
// para evitar loops cuando hay sesión obsoleta pero el usuario ya no existe en DB.
if user, err := auth.PortalUser(c); err == nil && user != nil {
if auth.IsPortalLoggedIn(c) {
return c.Redirect("/portal/dashboard")
}
return c.Render("portal/login", fiber.Map{
@@ -31,14 +27,11 @@ func PortalLoginPost(c *fiber.Ctx) error {
password := c.FormValue("password")
u, err := models.CheckPortalLogin(email, password)
if err != nil {
log.Printf("[PORTAL LOGIN] Fallo para %q: %v", email, err)
return c.Redirect("/portal/login?error=" + url.QueryEscape(err.Error()))
}
if err := auth.SetPortalSession(c, u.ID); err != nil {
log.Printf("[PORTAL LOGIN] Error guardando sesión para usuario %d: %v", u.ID, err)
return c.Redirect("/portal/login?error=Error+interno")
}
log.Printf("[PORTAL LOGIN] OK usuario %d (%s)", u.ID, email)
return c.Redirect("/portal/dashboard")
}
@@ -52,14 +45,12 @@ func PortalLogout(c *fiber.Ctx) error {
func PortalDashboard(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
log.Println("[PORTAL DASHBOARD] Usuario no en locals, redirigiendo a login")
return c.Redirect("/portal/login")
}
// Recargar con accesos
fullUser, err := models.GetPortalUserByID(u.ID)
if err != nil {
log.Printf("[PORTAL DASHBOARD] Error cargando usuario %d: %v", u.ID, err)
return c.Redirect("/portal/login")
}
@@ -87,7 +78,7 @@ func PortalDashboard(c *fiber.Ctx) error {
"portalUser": fullUser,
"proyectos": proyectos,
"grupos": grupos,
"isPartner": fullUser.Rol == "partner" || (fullUser.Role != nil && fullUser.Role.EsPortalPartner),
"isPartner": fullUser.Rol == "partner",
}, "layouts/portal")
}
@@ -229,7 +220,6 @@ 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)
}
@@ -264,7 +254,6 @@ func PortalResponderTicket(c *fiber.Ctx) error {
if err := models.CreateTicketMensaje(msg); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
go services.DispatchTicketRespuestaCliente(ticket, req.Contenido, ticket.Proyecto.Nombre)
return c.Status(201).JSON(msg)
}
@@ -335,42 +324,3 @@ func PortalDownloadFactura(c *fiber.Ctx) error {
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, f.OriginalName))
return c.SendFile(clean)
}
// ─── Notificaciones in-app del portal user ────────────────────────────────────
func PortalGetNotifs(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
soloNoLeidas := c.Query("no_leidas") == "1"
items, err := models.GetSistemaNotifs("portal_user", u.ID, soloNoLeidas)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
count := models.CountUnreadNotifs("portal_user", u.ID)
return c.JSON(fiber.Map{"items": items, "unread": count})
}
func PortalMarcarNotifLeida(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
id, _ := c.ParamsInt("id")
if err := models.MarcarNotifLeida(uint(id)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func PortalMarcarTodasLeidas(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
if err := models.MarcarTodasLeidas("portal_user", u.ID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
+12 -60
View File
@@ -8,7 +8,6 @@ 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 {
@@ -30,14 +29,12 @@ 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,
"portalRoles": portalRoles,
"items": items,
"total": total,
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
"page": page,
"clientes": clientes,
})
}
@@ -47,7 +44,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"`
}
var req Req
@@ -61,23 +58,15 @@ func CreatePortalUsuario(c *fiber.Ctx) error {
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Error al hashear contraseña"})
}
// Derivar Rol del tipo de rol seleccionado
rol := "cliente"
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"
}
}
if req.Rol == "" {
req.Rol = "cliente"
}
u := &models.PortalUser{
Nombre: req.Nombre,
Email: strings.ToLower(strings.TrimSpace(req.Email)),
Password: hashed,
ClienteID: req.ClienteID,
RoleID: req.RoleID,
Rol: rol,
Rol: req.Rol,
Activo: true,
Notas: req.Notas,
}
@@ -98,7 +87,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"`
}
@@ -106,22 +95,11 @@ 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 := "cliente"
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,
RoleID: req.RoleID,
Rol: rol,
Rol: req.Rol,
Activo: req.Activo,
Notas: req.Notas,
}
@@ -131,7 +109,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)
}
@@ -173,29 +151,3 @@ 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})
}
+5 -31
View File
@@ -12,7 +12,6 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// ─── Admin pages ──────────────────────────────────────────────────────────────
@@ -67,7 +66,7 @@ func LoadProyectos(c *fiber.Ctx) error {
func CreateProyecto(c *fiber.Ctx) error {
type Req struct {
ClienteID string `json:"cliente_id"`
ClienteID uint `json:"cliente_id"`
Nombre string `json:"nombre"`
Slug string `json:"slug"`
Descripcion string `json:"descripcion"`
@@ -78,8 +77,7 @@ func CreateProyecto(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
clienteID, _ := strconv.ParseUint(req.ClienteID, 10, 64)
if clienteID == 0 || strings.TrimSpace(req.Nombre) == "" {
if req.ClienteID == 0 || strings.TrimSpace(req.Nombre) == "" {
return c.Status(400).JSON(fiber.Map{"error": "cliente y nombre son requeridos"})
}
if req.Slug == "" {
@@ -92,7 +90,7 @@ func CreateProyecto(c *fiber.Ctx) error {
req.Estado = "activo"
}
p := &models.Proyecto{
ClienteID: uint(clienteID),
ClienteID: req.ClienteID,
Nombre: req.Nombre,
Slug: req.Slug,
Descripcion: req.Descripcion,
@@ -111,7 +109,7 @@ func UpdateProyecto(c *fiber.Ctx) error {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
type Req struct {
ClienteID string `json:"cliente_id"`
ClienteID uint `json:"cliente_id"`
Nombre string `json:"nombre"`
Slug string `json:"slug"`
Descripcion string `json:"descripcion"`
@@ -123,9 +121,8 @@ func UpdateProyecto(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
updateClienteID, _ := strconv.ParseUint(req.ClienteID, 10, 64)
p := &models.Proyecto{
ClienteID: uint(updateClienteID),
ClienteID: req.ClienteID,
Nombre: req.Nombre,
Slug: req.Slug,
Descripcion: req.Descripcion,
@@ -459,11 +456,6 @@ func AdminResponderTicket(c *fiber.Ctx) error {
}
// Cambiar estado a en_progreso si estaba abierto
_ = models.UpdateTicketEstado(uint(ticketID), "en_progreso")
// Notificar al portal user
ticket, _ := models.GetTicketByID(uint(ticketID))
if ticket != nil {
go services.DispatchTicketRespuestaAdmin(ticket, req.Contenido, ticket.Proyecto.Nombre)
}
return c.Status(201).JSON(msg)
}
@@ -506,21 +498,3 @@ func mimeFromHeader(fh *multipart.FileHeader) string {
}
return ct
}
// ─── Tickets global (admin) ───────────────────────────────────────────────────
func TicketsAdminIndex(c *fiber.Ctx) error {
return c.Render("tickets_admin", fiber.Map{
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}, "layouts/main")
}
func GetAllTicketsAdmin(c *fiber.Ctx) error {
estado := c.Query("estado", "todos")
items, err := models.GetAllTickets(estado)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(items)
}
+1 -3
View File
@@ -7,13 +7,11 @@ import (
)
// PortalAuth protege las rutas del portal de clientes.
// Si no hay sesión válida, destruye la sesión obsoleta y redirige a /portal/login.
// Si no hay sesión activa, 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)
-5
View File
@@ -29,9 +29,4 @@ func PortalRoutes(app fiber.Router) {
// Descargas
portal.Get("/entregables/:id/download", controllers.PortalDownloadEntregable)
portal.Get("/facturas/:id/download", controllers.PortalDownloadFactura)
// Notificaciones in-app del portal user
portal.Get("/mis-notifs", controllers.PortalGetNotifs)
portal.Put("/mis-notifs/:id/leida", controllers.PortalMarcarNotifLeida)
portal.Post("/mis-notifs/marcar-todas", controllers.PortalMarcarTodasLeidas)
}
-37
View File
@@ -154,26 +154,6 @@ func UserRoutes(app fiber.Router) {
protected.Get("/cloudflare/zones/:zone_id/ssl", controllers.GetCloudflareSSL)
protected.Get("/cloudflare/zones/:zone_id/firewall", controllers.GetCloudflareFirewall)
// ─── USITE Shield ─────────────────────────────────────────────────────────
protected.Get("/shield", middlewares.MenuMiddleware, controllers.ShieldIndex)
protected.Get("/loadshield", controllers.LoadShieldConfig)
protected.Post("/saveshield", controllers.SaveShieldConfig)
protected.Get("/shield/health", controllers.ShieldHealth)
protected.Get("/shield/extension-version", controllers.ShieldExtensionVersion)
protected.Get("/shield/logs", controllers.ShieldLogs)
protected.Get("/shield/logs/stats", controllers.ShieldLogStats)
protected.Get("/shield/review-requests", controllers.ShieldReviewRequests)
protected.Put("/shield/review-requests/:id/approve", controllers.ShieldApproveRequest)
protected.Put("/shield/review-requests/:id/reject", controllers.ShieldRejectRequest)
protected.Get("/shield/whitelist", controllers.ShieldWhitelist)
protected.Post("/shield/whitelist", controllers.ShieldAddWhitelist)
protected.Delete("/shield/whitelist/:domain", controllers.ShieldDeleteWhitelist)
protected.Get("/shield/blacklist", controllers.ShieldBlacklist)
protected.Post("/shield/blacklist", controllers.ShieldAddBlacklist)
protected.Delete("/shield/blacklist/:domain", controllers.ShieldDeleteBlacklist)
protected.Get("/shield/reputation/:domain", controllers.ShieldReputation)
protected.Put("/shield/reputation/:domain/score", controllers.ShieldAdjustScore)
// ─── Pasarelas de Pago ────────────────────────────────────────────
protected.Get("/pasarelas-pago", middlewares.MenuMiddleware, controllers.PasarelasPage)
// Bold
@@ -267,22 +247,6 @@ func UserRoutes(app fiber.Router) {
protected.Put("/proyectos/:id/tickets/:ticketID/estado", controllers.UpdateTicketEstadoAdmin)
protected.Post("/proyectos/:id/tickets/:ticketID/mensaje", controllers.AdminResponderTicket)
// Tickets global (todos los proyectos)
protected.Get("/tickets", middlewares.MenuMiddleware, controllers.TicketsAdminIndex)
protected.Get("/tickets/data", controllers.GetAllTicketsAdmin)
protected.Put("/tickets/:ticketID/estado", controllers.UpdateTicketEstadoAdmin)
protected.Post("/tickets/:ticketID/mensaje", controllers.AdminResponderTicket)
// Configuración de notificaciones
protected.Get("/notif-config", middlewares.MenuMiddleware, controllers.NotifConfigIndex)
protected.Get("/notif-config/data", controllers.GetNotifConfigs)
protected.Post("/notif-config", controllers.SaveNotifConfig)
// Notificaciones in-app del admin (bell)
protected.Get("/mis-notifs", controllers.GetMisNotifs)
protected.Put("/mis-notifs/:id/leida", controllers.MarcarNotifLeida)
protected.Post("/mis-notifs/marcar-todas", controllers.MarcarTodasLeidas)
protected.Get("/portal-usuarios", middlewares.MenuMiddleware, controllers.PortalUsuariosIndex)
protected.Get("/loadportalusuarios", controllers.LoadPortalUsuarios)
protected.Post("/portal-usuarios", controllers.CreatePortalUsuario)
@@ -290,7 +254,6 @@ 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)