up
This commit is contained in:
+101
-176
@@ -81,9 +81,8 @@ func Migrate() {
|
|||||||
&models.ProyectoEntregable{},
|
&models.ProyectoEntregable{},
|
||||||
&models.ProyectoTicket{},
|
&models.ProyectoTicket{},
|
||||||
&models.TicketMensaje{},
|
&models.TicketMensaje{},
|
||||||
&models.Factura{}, // Sistema de notificaciones
|
&models.Factura{},
|
||||||
&models.NotifEventoConfig{},
|
); err != nil {
|
||||||
&models.SistemaNotificacion{}, ); err != nil {
|
|
||||||
log.Fatalf("Error during main migration: %v", err)
|
log.Fatalf("Error during main migration: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,31 +277,6 @@ func SeedPlantillasBase() {
|
|||||||
log.Println("[SEED] SeedPlantillasBase completado.")
|
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.
|
// MigrateRenovaciones crea/actualiza las tablas del módulo de Renovaciones.
|
||||||
// Es idempotente: GORM AutoMigrate solo añade columnas/tablas nuevas, nunca las borra.
|
// Es idempotente: GORM AutoMigrate solo añade columnas/tablas nuevas, nunca las borra.
|
||||||
func MigrateRenovaciones() {
|
func MigrateRenovaciones() {
|
||||||
@@ -737,7 +711,27 @@ func SeedSaas() {
|
|||||||
log.Println("[SEED] Seed de SaaS completado.")
|
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() {
|
func SeedTelegram() {
|
||||||
db := app.Http.Database.DB
|
db := app.Http.Database.DB
|
||||||
|
|
||||||
@@ -747,160 +741,23 @@ func SeedTelegram() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
entries := []struct{ title, desc, url string }{
|
url := "/app/telegram"
|
||||||
{"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
|
|
||||||
}
|
|
||||||
|
|
||||||
var sub models.Submodules
|
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{
|
sub = models.Submodules{
|
||||||
Title: "Shield",
|
Title: "Telegram",
|
||||||
Description: "Administración de la API de seguridad USITE Shield",
|
Description: "Configuración de bots de Telegram para notificaciones",
|
||||||
Url: "/app/shield",
|
Url: url,
|
||||||
ModuleId: modulo.ID,
|
ModuleId: modulo.ID,
|
||||||
ModifiedAt: time.Now(),
|
ModifiedAt: time.Now(),
|
||||||
}
|
}
|
||||||
if err := db.Create(&sub).Error; err != nil {
|
if err2 := db.Create(&sub).Error; err2 != nil {
|
||||||
log.Printf("[SEED] Error creando submódulo Shield: %v", err)
|
log.Printf("[SEED] Error creando submódulo Telegram: %v", err2)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("[SEED] Submódulo 'Shield' creado (ID %d)", sub.ID)
|
log.Printf("[SEED] Submódulo 'Telegram' creado (ID %d)", sub.ID)
|
||||||
} else {
|
} 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
|
var roles []models.Roles
|
||||||
@@ -910,5 +767,73 @@ func SeedShield() {
|
|||||||
for _, rol := range roles {
|
for _, rol := range roles {
|
||||||
db.Model(&rol).Association("Submodules").Append(&sub)
|
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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
@@ -15,20 +14,11 @@ const portalSessionKey = "portal_user_id"
|
|||||||
func PortalUser(c *fiber.Ctx) (*models.PortalUser, error) {
|
func PortalUser(c *fiber.Ctx) (*models.PortalUser, error) {
|
||||||
store := app.Http.Session.Get(c)
|
store := app.Http.Session.Get(c)
|
||||||
raw := store.Get(portalSessionKey)
|
raw := store.Get(portalSessionKey)
|
||||||
log.Printf("[PORTAL SESSION] GET %s → cookie=%q raw=%v", c.Path(), c.Cookies("session_id"), raw)
|
|
||||||
if raw == nil {
|
if raw == nil {
|
||||||
return nil, errors.New("no portal session")
|
return nil, errors.New("no portal session")
|
||||||
}
|
}
|
||||||
var id uint
|
id, ok := raw.(uint)
|
||||||
switch v := raw.(type) {
|
if !ok {
|
||||||
case uint:
|
|
||||||
id = v
|
|
||||||
case int64:
|
|
||||||
id = uint(v)
|
|
||||||
case float64:
|
|
||||||
id = uint(v)
|
|
||||||
default:
|
|
||||||
log.Printf("[PORTAL SESSION] type assertion failed: %T", raw)
|
|
||||||
return nil, errors.New("invalid portal session")
|
return nil, errors.New("invalid portal session")
|
||||||
}
|
}
|
||||||
u, err := models.GetPortalUserByID(id)
|
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.
|
// SetPortalSession guarda el portal_user_id en la sesión.
|
||||||
func SetPortalSession(c *fiber.Ctx, userID uint) error {
|
func SetPortalSession(c *fiber.Ctx, userID uint) error {
|
||||||
store := app.Http.Session.Get(c)
|
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)
|
store.Set(portalSessionKey, userID)
|
||||||
if err := store.Save(); err != nil {
|
return store.Save()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DestroyPortalSession elimina la sesión del portal.
|
// 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.
|
// - Rol "partner": accede a los proyectos de todos sus ClienteIDs via PortalAccesos.
|
||||||
type PortalUser struct {
|
type PortalUser struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
Nombre string `json:"nombre" gorm:"column:nombre;not null"`
|
Nombre string `json:"nombre" gorm:"column:nombre;not null"`
|
||||||
Email string `json:"email" gorm:"column:email;uniqueIndex;not null"`
|
Email string `json:"email" gorm:"column:email;uniqueIndex;not null"`
|
||||||
Password string `json:"-" gorm:"column:password;type:text"`
|
Password string `json:"-" gorm:"column:password;type:text"`
|
||||||
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
|
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"` // nil si es partner
|
||||||
Cliente *Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
Cliente *Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
||||||
Rol string `json:"rol" gorm:"column:rol;default:'cliente'"`
|
Rol string `json:"rol" gorm:"column:rol;default:'cliente'"` // cliente|partner
|
||||||
RoleID *uint `json:"role_id" gorm:"column:role_id;index"`
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||||
Role *Roles `json:"role" gorm:"foreignKey:RoleID"`
|
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
PortalAccesos []PortalAcceso `json:"portal_accesos" gorm:"foreignKey:PortalUserID"`
|
||||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
|
||||||
TelegramChatID string `json:"telegram_chat_id" gorm:"column:telegram_chat_id"`
|
|
||||||
Telefono string `json:"telefono" gorm:"column:telefono"`
|
|
||||||
Pais string `json:"pais" gorm:"column:pais"`
|
|
||||||
PortalAccesos []PortalAcceso `json:"portal_accesos" gorm:"foreignKey:PortalUserID"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (PortalUser) TableName() string { return "portal_users" }
|
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) {
|
func GetAllPortalUsers(limit, offset int) ([]PortalUser, int64, error) {
|
||||||
var items []PortalUser
|
var items []PortalUser
|
||||||
var total int64
|
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 {
|
if err := db.Count(&total).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@@ -60,7 +55,7 @@ func GetAllPortalUsers(limit, offset int) ([]PortalUser, int64, error) {
|
|||||||
|
|
||||||
func GetPortalUserByID(id uint) (*PortalUser, error) {
|
func GetPortalUserByID(id uint) (*PortalUser, error) {
|
||||||
var item PortalUser
|
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
|
return &item, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,12 +75,8 @@ func UpdatePortalUser(u *PortalUser) error {
|
|||||||
"email": u.Email,
|
"email": u.Email,
|
||||||
"cliente_id": u.ClienteID,
|
"cliente_id": u.ClienteID,
|
||||||
"rol": u.Rol,
|
"rol": u.Rol,
|
||||||
"role_id": u.RoleID,
|
"activo": u.Activo,
|
||||||
"activo": u.Activo,
|
"notas": u.Notas,
|
||||||
"notas": u.Notas,
|
|
||||||
"telegram_chat_id": u.TelegramChatID,
|
|
||||||
"telefono": u.Telefono,
|
|
||||||
"pais": u.Pais,
|
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +92,41 @@ func DeletePortalUser(id uint) error {
|
|||||||
return app.Http.Database.DB.Delete(&PortalUser{}, id).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 {
|
func AddPortalAcceso(portalUserID, clienteID uint) error {
|
||||||
// Evitar duplicados
|
// Evitar duplicados
|
||||||
var existing PortalAcceso
|
var existing PortalAcceso
|
||||||
@@ -121,11 +147,7 @@ func RemovePortalAcceso(portalUserID, clienteID uint) error {
|
|||||||
|
|
||||||
// GetClienteIDsForPortalUser devuelve todos los clienteIDs accesibles para un portal user.
|
// GetClienteIDsForPortalUser devuelve todos los clienteIDs accesibles para un portal user.
|
||||||
func GetClienteIDsForPortalUser(u *PortalUser) []uint {
|
func GetClienteIDsForPortalUser(u *PortalUser) []uint {
|
||||||
isPartner := u.Rol == "partner"
|
if u.Rol == "partner" {
|
||||||
if u.Role != nil {
|
|
||||||
isPartner = u.Role.EsPortalPartner
|
|
||||||
}
|
|
||||||
if isPartner {
|
|
||||||
ids := make([]uint, 0, len(u.PortalAccesos))
|
ids := make([]uint, 0, len(u.PortalAccesos))
|
||||||
for _, a := range u.PortalAccesos {
|
for _, a := range u.PortalAccesos {
|
||||||
ids = append(ids, a.ClienteID)
|
ids = append(ids, a.ClienteID)
|
||||||
@@ -153,12 +175,3 @@ func CheckPortalLogin(email, password string) (*PortalUser, error) {
|
|||||||
}
|
}
|
||||||
return u, nil
|
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) {
|
func GetProyectosByClienteIDs(clienteIDs []uint) ([]Proyecto, error) {
|
||||||
if len(clienteIDs) == 0 {
|
|
||||||
return []Proyecto{}, nil
|
|
||||||
}
|
|
||||||
var items []Proyecto
|
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
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import (
|
|||||||
|
|
||||||
type ProyectoTicket struct {
|
type ProyectoTicket struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
||||||
Proyecto Proyecto `json:"proyecto" gorm:"foreignKey:ProyectoID"`
|
|
||||||
PortalUserID uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
|
PortalUserID uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
|
||||||
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
|
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
|
||||||
Titulo string `json:"titulo" gorm:"column:titulo"`
|
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
|
Preload("Mensajes").Order("created_at DESC").Find(&items).Error
|
||||||
return items, err
|
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 ──────────────────────────────────────────────────────
|
// ─── DispatchTicketNuevo ──────────────────────────────────────────────────────
|
||||||
// Llamar cuando el portal user crea un nuevo ticket.
|
// Notifica al admin cuando un portal user crea un ticket.
|
||||||
// Notifica al ADMIN según su configuración de canales.
|
|
||||||
func DispatchTicketNuevo(ticket *models.ProyectoTicket, portalUser *models.PortalUser, proyectoNombre string) {
|
func DispatchTicketNuevo(ticket *models.ProyectoTicket, portalUser *models.PortalUser, proyectoNombre string) {
|
||||||
cfg := models.GetNotifConfig("ticket_nuevo", "admin")
|
if ticket == nil || portalUser == nil {
|
||||||
if cfg == nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ticketURL := "/app/tickets"
|
msg := fmt.Sprintf("🎫 <b>Nuevo ticket</b>\nProyecto: <b>%s</b>\nCliente: %s\nTítulo: <b>%s</b>\n\n%s",
|
||||||
titulo := fmt.Sprintf("Nuevo ticket: %s", ticket.Titulo)
|
proyectoNombre, ticket.AutorNombre, ticket.Titulo, ticket.Descripcion)
|
||||||
cuerpo := fmt.Sprintf("Cliente %s abrió: %s", ticket.AutorNombre, ticket.Titulo)
|
sendTelegramAdmin(msg)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DispatchTicketRespuestaCliente ──────────────────────────────────────────
|
// ─── DispatchTicketRespuestaCliente ──────────────────────────────────────────
|
||||||
// Llamar cuando el portal user responde un ticket.
|
// Notifica al admin cuando el portal user responde un ticket.
|
||||||
// Notifica al ADMIN según su configuración de canales.
|
|
||||||
func DispatchTicketRespuestaCliente(ticket *models.ProyectoTicket, contenido string, proyectoNombre string) {
|
func DispatchTicketRespuestaCliente(ticket *models.ProyectoTicket, contenido string, proyectoNombre string) {
|
||||||
cfg := models.GetNotifConfig("ticket_respuesta_cliente", "admin")
|
if ticket == nil {
|
||||||
if cfg == nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ticketURL := "/app/tickets"
|
msg := fmt.Sprintf("💬 <b>Respuesta de cliente</b>\nProyecto: <b>%s</b>\nCliente: %s\n\n%s",
|
||||||
titulo := fmt.Sprintf("Respuesta de cliente: %s", ticket.Titulo)
|
proyectoNombre, ticket.AutorNombre, contenido)
|
||||||
cuerpo := fmt.Sprintf("%s respondió: %s", ticket.AutorNombre, contenido)
|
sendTelegramAdmin(msg)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DispatchTicketRespuestaAdmin ─────────────────────────────────────────────
|
// ─── DispatchTicketRespuestaAdmin ─────────────────────────────────────────────
|
||||||
// Llamar cuando el admin responde un ticket.
|
// Notifica al portal user cuando el admin responde un ticket.
|
||||||
// Notifica al PORTAL USER según su configuración de canales.
|
|
||||||
func DispatchTicketRespuestaAdmin(ticket *models.ProyectoTicket, contenido string, proyectoNombre string) {
|
func DispatchTicketRespuestaAdmin(ticket *models.ProyectoTicket, contenido string, proyectoNombre string) {
|
||||||
cfg := models.GetNotifConfig("ticket_respuesta_admin", "portal_user")
|
if ticket == nil {
|
||||||
if cfg == nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Obtener portal user para saber email y telegram
|
|
||||||
portalUser, err := models.GetPortalUserByID(ticket.PortalUserID)
|
portalUser, err := models.GetPortalUserByID(ticket.PortalUserID)
|
||||||
if err != nil || portalUser == nil {
|
if err != nil || portalUser == nil {
|
||||||
log.Printf("[Notif] PortalUser %d no encontrado: %v", ticket.PortalUserID, err)
|
log.Printf("[Notif] PortalUser %d no encontrado: %v", ticket.PortalUserID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if portalUser.Email == "" {
|
||||||
portalURL := fmt.Sprintf("/portal/proyecto/%s", ticket.Proyecto.Slug)
|
return
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// 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 {
|
func getAdminEmail() string {
|
||||||
cfg, err := models.GetSmtpConfig()
|
cfg, err := models.GetSmtpConfig()
|
||||||
if err != nil || cfg == nil {
|
if err != nil || cfg == nil {
|
||||||
@@ -128,7 +71,6 @@ func getAdminEmail() string {
|
|||||||
return cfg.FromAddress
|
return cfg.FromAddress
|
||||||
}
|
}
|
||||||
|
|
||||||
// getAdminBotToken devuelve el token del primer TelegramConfig activo.
|
|
||||||
func getAdminBotToken() string {
|
func getAdminBotToken() string {
|
||||||
configs, err := models.GetAllTelegramConfigs()
|
configs, err := models.GetAllTelegramConfigs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -142,7 +84,6 @@ func getAdminBotToken() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendTelegramAdmin envía un mensaje usando el primer TelegramConfig activo.
|
|
||||||
func sendTelegramAdmin(mensaje string) {
|
func sendTelegramAdmin(mensaje string) {
|
||||||
configs, err := models.GetAllTelegramConfigs()
|
configs, err := models.GetAllTelegramConfigs()
|
||||||
if err != nil {
|
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">
|
<body class="min-h-screen flex flex-col">
|
||||||
|
|
||||||
<!-- Navbar -->
|
<!-- Navbar -->
|
||||||
<header class="bg-white border-b border-slate-200 sticky top-0 z-30"
|
<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)">
|
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
|
<div class="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<a href="/portal/dashboard" class="flex items-center gap-2 font-bold text-slate-800 text-lg">
|
<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>
|
<span>Portal de Clientes</span>
|
||||||
</a>
|
</a>
|
||||||
<!-- Right: bell + user -->
|
<!-- User -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-4">
|
||||||
<!-- 🔔 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 -->
|
|
||||||
<span class="text-sm text-slate-600 hidden sm:block">
|
<span class="text-sm text-slate-600 hidden sm:block">
|
||||||
{{ if .portalUser }}{{ .portalUser.Nombre }}{{ end }}
|
{{ if .portalUser }}{{ .portalUser.Nombre }}{{ end }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div x-data="portalUsuariosApp()" x-init="init()" class="p-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>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-slate-800">Usuarios del Portal</h1>
|
<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>
|
<p class="text-sm text-slate-500 mt-1">Accesos del portal de clientes</p>
|
||||||
@@ -11,15 +11,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- Tabla -->
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-x-auto">
|
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-x-auto">
|
||||||
<table class="w-full text-sm">
|
<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 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 text-slate-600" x-text="u.email"></td>
|
||||||
<td class="px-4 py-3">
|
<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>
|
||||||
<td class="px-4 py-3 text-slate-500 text-xs">
|
<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>
|
<span x-text="u.cliente?.empresa || u.cliente?.nombre"></span>
|
||||||
</template>
|
</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">
|
<div class="flex flex-wrap gap-1">
|
||||||
<template x-for="acc in (u.portal_accesos||[])" :key="acc.ID">
|
<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">
|
<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">
|
<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>
|
<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>
|
||||||
<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">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -94,16 +82,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="label">Rol</label>
|
<label class="label">Rol</label>
|
||||||
<select x-model.number="form.role_id" class="input-field w-full">
|
<select x-model="form.rol" class="input-field w-full">
|
||||||
<option value="">Sin rol asignado</option>
|
<option value="cliente">Cliente</option>
|
||||||
<template x-for="r in portalRoles" :key="r.ID">
|
<option value="partner">Partner</option>
|
||||||
<option :value="r.ID">
|
|
||||||
<span x-text="r.name + (r.es_portal_partner ? ' (Partner)' : ' (Cliente)')"></span>
|
|
||||||
</option>
|
|
||||||
</template>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<label class="label">Cliente asignado</label>
|
||||||
<select x-model.number="form.cliente_id" class="input-field w-full">
|
<select x-model.number="form.cliente_id" class="input-field w-full">
|
||||||
<option value="">Sin asignar</option>
|
<option value="">Sin asignar</option>
|
||||||
@@ -157,34 +141,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function portalUsuariosApp() {
|
function portalUsuariosApp() {
|
||||||
return {
|
return {
|
||||||
items: [], clientes: [], portalRoles: [], loading: false, saving: false,
|
items: [], clientes: [], loading: false, saving: false,
|
||||||
showModal: false, showDelete: false, showAccesoModal: false, showCredModal: false,
|
showModal: false, showDelete: false, showAccesoModal: false,
|
||||||
editId: null, deleteId: null, error: '',
|
editId: null, deleteId: null, error: '',
|
||||||
accesoUserId: null, accesoClienteId: '',
|
accesoUserId: null, accesoClienteId: '',
|
||||||
credUserId: null, credEmail: '', credPassword: '', credMsg: '',
|
form: { nombre:'', email:'', password:'', rol:'cliente', cliente_id:'', activo:true, notas:'' },
|
||||||
form: { nombre:'', email:'', password:'', role_id:'', cliente_id:'', activo:true, notas:'' },
|
|
||||||
|
|
||||||
async init() { await this.load(); },
|
async init() { await this.load(); },
|
||||||
|
|
||||||
@@ -194,25 +160,23 @@ function portalUsuariosApp() {
|
|||||||
const r = await axios.get('/app/loadportalusuarios');
|
const r = await axios.get('/app/loadportalusuarios');
|
||||||
this.items = r.data.items || [];
|
this.items = r.data.items || [];
|
||||||
this.clientes = r.data.clientes || [];
|
this.clientes = r.data.clientes || [];
|
||||||
this.portalRoles = r.data.portalRoles || [];
|
|
||||||
} finally { this.loading = false; }
|
} finally { this.loading = false; }
|
||||||
},
|
},
|
||||||
|
|
||||||
openCreate() {
|
openCreate() {
|
||||||
this.editId=null; this.error='';
|
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;
|
this.showModal=true;
|
||||||
},
|
},
|
||||||
openEdit(u) {
|
openEdit(u) {
|
||||||
this.editId=u.ID; this.error='';
|
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;
|
this.showModal=true;
|
||||||
},
|
},
|
||||||
async save() {
|
async save() {
|
||||||
this.saving=true; this.error='';
|
this.saving=true; this.error='';
|
||||||
const payload={...this.form};
|
const payload={...this.form};
|
||||||
if(payload.cliente_id==='') payload.cliente_id=null;
|
if(payload.cliente_id==='') payload.cliente_id=null;
|
||||||
if(payload.role_id==='') payload.role_id=null;
|
|
||||||
try {
|
try {
|
||||||
if(this.editId) await axios.put(`/app/portal-usuarios/${this.editId}`, payload);
|
if(this.editId) await axios.put(`/app/portal-usuarios/${this.editId}`, payload);
|
||||||
else await axios.post('/app/portal-usuarios', 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 axios.delete(`/app/portal-usuarios/${u.ID}/acceso/${clienteId}`);
|
||||||
await this.load();
|
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>
|
</script>
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
<span class="w-4 h-4 rounded-full border" :style="`background:{{ .proyecto.Color }}`"></span>
|
<span class="w-4 h-4 rounded-full border" :style="`background:{{ .proyecto.Color }}`"></span>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-bold text-slate-800">{{ .proyecto.Nombre }}</h1>
|
<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>
|
||||||
<div class="ml-auto flex items-center gap-3">
|
<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">
|
<span class="text-sm text-slate-500">
|
||||||
<strong>{{ .proyecto.Progreso }}%</strong> completado
|
<strong>{{ .proyecto.Progreso }}%</strong> completado
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func FacturasIndex(c *fiber.Ctx) error {
|
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 {
|
if err := models.UpdateFacturaArchivo(uint(id), savePath, file.Filename); err != nil {
|
||||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
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})
|
return c.JSON(fiber.Map{"ok": true, "archivo": savePath})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,23 +2,19 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/auth"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/auth"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
|
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func PortalLoginPage(c *fiber.Ctx) error {
|
func PortalLoginPage(c *fiber.Ctx) error {
|
||||||
// Usar PortalUser (validación completa) en vez de IsPortalLoggedIn (solo key)
|
if auth.IsPortalLoggedIn(c) {
|
||||||
// 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 {
|
|
||||||
return c.Redirect("/portal/dashboard")
|
return c.Redirect("/portal/dashboard")
|
||||||
}
|
}
|
||||||
return c.Render("portal/login", fiber.Map{
|
return c.Render("portal/login", fiber.Map{
|
||||||
@@ -31,14 +27,11 @@ func PortalLoginPost(c *fiber.Ctx) error {
|
|||||||
password := c.FormValue("password")
|
password := c.FormValue("password")
|
||||||
u, err := models.CheckPortalLogin(email, password)
|
u, err := models.CheckPortalLogin(email, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[PORTAL LOGIN] Fallo para %q: %v", email, err)
|
|
||||||
return c.Redirect("/portal/login?error=" + url.QueryEscape(err.Error()))
|
return c.Redirect("/portal/login?error=" + url.QueryEscape(err.Error()))
|
||||||
}
|
}
|
||||||
if err := auth.SetPortalSession(c, u.ID); err != nil {
|
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")
|
return c.Redirect("/portal/login?error=Error+interno")
|
||||||
}
|
}
|
||||||
log.Printf("[PORTAL LOGIN] OK usuario %d (%s)", u.ID, email)
|
|
||||||
return c.Redirect("/portal/dashboard")
|
return c.Redirect("/portal/dashboard")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,14 +45,12 @@ func PortalLogout(c *fiber.Ctx) error {
|
|||||||
func PortalDashboard(c *fiber.Ctx) error {
|
func PortalDashboard(c *fiber.Ctx) error {
|
||||||
u := middlewares.PortalUserFromLocals(c)
|
u := middlewares.PortalUserFromLocals(c)
|
||||||
if u == nil {
|
if u == nil {
|
||||||
log.Println("[PORTAL DASHBOARD] Usuario no en locals, redirigiendo a login")
|
|
||||||
return c.Redirect("/portal/login")
|
return c.Redirect("/portal/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recargar con accesos
|
// Recargar con accesos
|
||||||
fullUser, err := models.GetPortalUserByID(u.ID)
|
fullUser, err := models.GetPortalUserByID(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[PORTAL DASHBOARD] Error cargando usuario %d: %v", u.ID, err)
|
|
||||||
return c.Redirect("/portal/login")
|
return c.Redirect("/portal/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +78,7 @@ func PortalDashboard(c *fiber.Ctx) error {
|
|||||||
"portalUser": fullUser,
|
"portalUser": fullUser,
|
||||||
"proyectos": proyectos,
|
"proyectos": proyectos,
|
||||||
"grupos": grupos,
|
"grupos": grupos,
|
||||||
"isPartner": fullUser.Rol == "partner" || (fullUser.Role != nil && fullUser.Role.EsPortalPartner),
|
"isPartner": fullUser.Rol == "partner",
|
||||||
}, "layouts/portal")
|
}, "layouts/portal")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +220,6 @@ func PortalCrearTicket(c *fiber.Ctx) error {
|
|||||||
if err := models.CreateProyectoTicket(t); err != nil {
|
if err := models.CreateProyectoTicket(t); err != nil {
|
||||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
go services.DispatchTicketNuevo(t, u, proy.Nombre)
|
|
||||||
return c.Status(201).JSON(t)
|
return c.Status(201).JSON(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +254,6 @@ func PortalResponderTicket(c *fiber.Ctx) error {
|
|||||||
if err := models.CreateTicketMensaje(msg); err != nil {
|
if err := models.CreateTicketMensaje(msg); err != nil {
|
||||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
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)
|
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))
|
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, f.OriginalName))
|
||||||
return c.SendFile(clean)
|
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/gofiber/fiber/v2"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func PortalUsuariosIndex(c *fiber.Ctx) error {
|
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()})
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
clientes, _, _ := models.GetAllClientes(200, 0, "")
|
clientes, _, _ := models.GetAllClientes(200, 0, "")
|
||||||
portalRoles, _ := models.GetPortalRoles()
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"items": items,
|
"items": items,
|
||||||
"total": total,
|
"total": total,
|
||||||
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
||||||
"page": page,
|
"page": page,
|
||||||
"clientes": clientes,
|
"clientes": clientes,
|
||||||
"portalRoles": portalRoles,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +44,7 @@ func CreatePortalUsuario(c *fiber.Ctx) error {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
ClienteID *uint `json:"cliente_id"`
|
ClienteID *uint `json:"cliente_id"`
|
||||||
RoleID *uint `json:"role_id"`
|
Rol string `json:"rol"`
|
||||||
Notas string `json:"notas"`
|
Notas string `json:"notas"`
|
||||||
}
|
}
|
||||||
var req Req
|
var req Req
|
||||||
@@ -61,23 +58,15 @@ func CreatePortalUsuario(c *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(500).JSON(fiber.Map{"error": "Error al hashear contraseña"})
|
return c.Status(500).JSON(fiber.Map{"error": "Error al hashear contraseña"})
|
||||||
}
|
}
|
||||||
// Derivar Rol del tipo de rol seleccionado
|
if req.Rol == "" {
|
||||||
rol := "cliente"
|
req.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{
|
u := &models.PortalUser{
|
||||||
Nombre: req.Nombre,
|
Nombre: req.Nombre,
|
||||||
Email: strings.ToLower(strings.TrimSpace(req.Email)),
|
Email: strings.ToLower(strings.TrimSpace(req.Email)),
|
||||||
Password: hashed,
|
Password: hashed,
|
||||||
ClienteID: req.ClienteID,
|
ClienteID: req.ClienteID,
|
||||||
RoleID: req.RoleID,
|
Rol: req.Rol,
|
||||||
Rol: rol,
|
|
||||||
Activo: true,
|
Activo: true,
|
||||||
Notas: req.Notas,
|
Notas: req.Notas,
|
||||||
}
|
}
|
||||||
@@ -98,7 +87,7 @@ func UpdatePortalUsuario(c *fiber.Ctx) error {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
ClienteID *uint `json:"cliente_id"`
|
ClienteID *uint `json:"cliente_id"`
|
||||||
RoleID *uint `json:"role_id"`
|
Rol string `json:"rol"`
|
||||||
Activo bool `json:"activo"`
|
Activo bool `json:"activo"`
|
||||||
Notas string `json:"notas"`
|
Notas string `json:"notas"`
|
||||||
}
|
}
|
||||||
@@ -106,22 +95,11 @@ func UpdatePortalUsuario(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
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{
|
u := &models.PortalUser{
|
||||||
Nombre: req.Nombre,
|
Nombre: req.Nombre,
|
||||||
Email: strings.ToLower(strings.TrimSpace(req.Email)),
|
Email: strings.ToLower(strings.TrimSpace(req.Email)),
|
||||||
ClienteID: req.ClienteID,
|
ClienteID: req.ClienteID,
|
||||||
RoleID: req.RoleID,
|
Rol: req.Rol,
|
||||||
Rol: rol,
|
|
||||||
Activo: req.Activo,
|
Activo: req.Activo,
|
||||||
Notas: req.Notas,
|
Notas: req.Notas,
|
||||||
}
|
}
|
||||||
@@ -131,7 +109,7 @@ func UpdatePortalUsuario(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
// Actualizar password solo si se envió
|
// Actualizar password solo si se envió
|
||||||
if strings.TrimSpace(req.Password) != "" {
|
if strings.TrimSpace(req.Password) != "" {
|
||||||
hashed, err := app.Http.Hash.Create(req.Password)
|
hashed, err := app.Http.Hash.Create(req.Password)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = models.UpdatePortalUserPassword(uint(id), hashed)
|
_ = models.UpdatePortalUserPassword(uint(id), hashed)
|
||||||
}
|
}
|
||||||
@@ -173,29 +151,3 @@ func RemovePortalAcceso(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
return c.JSON(fiber.Map{"ok": true})
|
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/gofiber/fiber/v2"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Admin pages ──────────────────────────────────────────────────────────────
|
// ─── Admin pages ──────────────────────────────────────────────────────────────
|
||||||
@@ -67,7 +66,7 @@ func LoadProyectos(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
func CreateProyecto(c *fiber.Ctx) error {
|
func CreateProyecto(c *fiber.Ctx) error {
|
||||||
type Req struct {
|
type Req struct {
|
||||||
ClienteID string `json:"cliente_id"`
|
ClienteID uint `json:"cliente_id"`
|
||||||
Nombre string `json:"nombre"`
|
Nombre string `json:"nombre"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Descripcion string `json:"descripcion"`
|
Descripcion string `json:"descripcion"`
|
||||||
@@ -78,8 +77,7 @@ func CreateProyecto(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
clienteID, _ := strconv.ParseUint(req.ClienteID, 10, 64)
|
if req.ClienteID == 0 || strings.TrimSpace(req.Nombre) == "" {
|
||||||
if clienteID == 0 || strings.TrimSpace(req.Nombre) == "" {
|
|
||||||
return c.Status(400).JSON(fiber.Map{"error": "cliente y nombre son requeridos"})
|
return c.Status(400).JSON(fiber.Map{"error": "cliente y nombre son requeridos"})
|
||||||
}
|
}
|
||||||
if req.Slug == "" {
|
if req.Slug == "" {
|
||||||
@@ -92,7 +90,7 @@ func CreateProyecto(c *fiber.Ctx) error {
|
|||||||
req.Estado = "activo"
|
req.Estado = "activo"
|
||||||
}
|
}
|
||||||
p := &models.Proyecto{
|
p := &models.Proyecto{
|
||||||
ClienteID: uint(clienteID),
|
ClienteID: req.ClienteID,
|
||||||
Nombre: req.Nombre,
|
Nombre: req.Nombre,
|
||||||
Slug: req.Slug,
|
Slug: req.Slug,
|
||||||
Descripcion: req.Descripcion,
|
Descripcion: req.Descripcion,
|
||||||
@@ -111,7 +109,7 @@ func UpdateProyecto(c *fiber.Ctx) error {
|
|||||||
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
|
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
|
||||||
}
|
}
|
||||||
type Req struct {
|
type Req struct {
|
||||||
ClienteID string `json:"cliente_id"`
|
ClienteID uint `json:"cliente_id"`
|
||||||
Nombre string `json:"nombre"`
|
Nombre string `json:"nombre"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Descripcion string `json:"descripcion"`
|
Descripcion string `json:"descripcion"`
|
||||||
@@ -123,9 +121,8 @@ func UpdateProyecto(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
updateClienteID, _ := strconv.ParseUint(req.ClienteID, 10, 64)
|
|
||||||
p := &models.Proyecto{
|
p := &models.Proyecto{
|
||||||
ClienteID: uint(updateClienteID),
|
ClienteID: req.ClienteID,
|
||||||
Nombre: req.Nombre,
|
Nombre: req.Nombre,
|
||||||
Slug: req.Slug,
|
Slug: req.Slug,
|
||||||
Descripcion: req.Descripcion,
|
Descripcion: req.Descripcion,
|
||||||
@@ -459,11 +456,6 @@ func AdminResponderTicket(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
// Cambiar estado a en_progreso si estaba abierto
|
// Cambiar estado a en_progreso si estaba abierto
|
||||||
_ = models.UpdateTicketEstado(uint(ticketID), "en_progreso")
|
_ = 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)
|
return c.Status(201).JSON(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,21 +498,3 @@ func mimeFromHeader(fh *multipart.FileHeader) string {
|
|||||||
}
|
}
|
||||||
return ct
|
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.
|
// 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 {
|
func PortalAuth() fiber.Handler {
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
user, err := auth.PortalUser(c)
|
user, err := auth.PortalUser(c)
|
||||||
if err != nil || user == nil {
|
if err != nil || user == nil {
|
||||||
// Limpiar sesión inválida/obsoleta para romper posibles redirect loops
|
|
||||||
_ = auth.DestroyPortalSession(c)
|
|
||||||
return c.Redirect("/portal/login")
|
return c.Redirect("/portal/login")
|
||||||
}
|
}
|
||||||
c.Locals("portalUser", user)
|
c.Locals("portalUser", user)
|
||||||
|
|||||||
@@ -29,9 +29,4 @@ func PortalRoutes(app fiber.Router) {
|
|||||||
// Descargas
|
// Descargas
|
||||||
portal.Get("/entregables/:id/download", controllers.PortalDownloadEntregable)
|
portal.Get("/entregables/:id/download", controllers.PortalDownloadEntregable)
|
||||||
portal.Get("/facturas/:id/download", controllers.PortalDownloadFactura)
|
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/ssl", controllers.GetCloudflareSSL)
|
||||||
protected.Get("/cloudflare/zones/:zone_id/firewall", controllers.GetCloudflareFirewall)
|
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 ────────────────────────────────────────────
|
// ─── Pasarelas de Pago ────────────────────────────────────────────
|
||||||
protected.Get("/pasarelas-pago", middlewares.MenuMiddleware, controllers.PasarelasPage)
|
protected.Get("/pasarelas-pago", middlewares.MenuMiddleware, controllers.PasarelasPage)
|
||||||
// Bold
|
// Bold
|
||||||
@@ -267,22 +247,6 @@ func UserRoutes(app fiber.Router) {
|
|||||||
protected.Put("/proyectos/:id/tickets/:ticketID/estado", controllers.UpdateTicketEstadoAdmin)
|
protected.Put("/proyectos/:id/tickets/:ticketID/estado", controllers.UpdateTicketEstadoAdmin)
|
||||||
protected.Post("/proyectos/:id/tickets/:ticketID/mensaje", controllers.AdminResponderTicket)
|
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("/portal-usuarios", middlewares.MenuMiddleware, controllers.PortalUsuariosIndex)
|
||||||
protected.Get("/loadportalusuarios", controllers.LoadPortalUsuarios)
|
protected.Get("/loadportalusuarios", controllers.LoadPortalUsuarios)
|
||||||
protected.Post("/portal-usuarios", controllers.CreatePortalUsuario)
|
protected.Post("/portal-usuarios", controllers.CreatePortalUsuario)
|
||||||
@@ -290,7 +254,6 @@ func UserRoutes(app fiber.Router) {
|
|||||||
protected.Delete("/portal-usuarios/:id", controllers.DeletePortalUsuario)
|
protected.Delete("/portal-usuarios/:id", controllers.DeletePortalUsuario)
|
||||||
protected.Post("/portal-usuarios/:id/acceso", controllers.AddPortalAcceso)
|
protected.Post("/portal-usuarios/:id/acceso", controllers.AddPortalAcceso)
|
||||||
protected.Delete("/portal-usuarios/:id/acceso/:clienteID", controllers.RemovePortalAcceso)
|
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("/facturas", middlewares.MenuMiddleware, controllers.FacturasIndex)
|
||||||
protected.Get("/loadfacturas", controllers.LoadFacturas)
|
protected.Get("/loadfacturas", controllers.LoadFacturas)
|
||||||
|
|||||||
Reference in New Issue
Block a user