up
This commit is contained in:
+101
-176
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }} · 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 }} · 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>
|
||||
|
||||
@@ -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,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})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user