From 76a0c28b1640a0a73c62be45ccc12c8b46fd1a4e Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 16 May 2026 13:44:32 -0500 Subject: [PATCH] up --- migrations/migrate.go | 277 +++++++----------- pkg/auth/portal.go | 26 +- pkg/models/portal_user.go | 85 +++--- pkg/models/proyecto.go | 5 +- pkg/models/proyecto_ticket.go | 13 +- pkg/services/notif_dispatch.go | 174 ++--------- resources/views/layouts/portal.html | 77 +---- resources/views/portal_usuarios.html | 76 +---- resources/views/proyecto_detalle.html | 4 +- rest/controllers/factura_controller.go | 5 - rest/controllers/portal_controller.go | 54 +--- rest/controllers/portal_usuario_controller.go | 72 +---- rest/controllers/proyecto_controller.go | 36 +-- rest/middlewares/portal_auth.go | 4 +- rest/routes/portal.go | 5 - rest/routes/user.go | 37 --- 16 files changed, 225 insertions(+), 725 deletions(-) diff --git a/migrations/migrate.go b/migrations/migrate.go index bc6b992..fc6831c 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -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.") } diff --git a/pkg/auth/portal.go b/pkg/auth/portal.go index 98d2536..906d196 100644 --- a/pkg/auth/portal.go +++ b/pkg/auth/portal.go @@ -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. diff --git a/pkg/models/portal_user.go b/pkg/models/portal_user.go index d5613e3..01a8d6f 100644 --- a/pkg/models/portal_user.go +++ b/pkg/models/portal_user.go @@ -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 -} diff --git a/pkg/models/proyecto.go b/pkg/models/proyecto.go index 4afb518..2e57fca 100644 --- a/pkg/models/proyecto.go +++ b/pkg/models/proyecto.go @@ -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 } diff --git a/pkg/models/proyecto_ticket.go b/pkg/models/proyecto_ticket.go index ac0dce9..45bdbd2 100644 --- a/pkg/models/proyecto_ticket.go +++ b/pkg/models/proyecto_ticket.go @@ -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 -} diff --git a/pkg/services/notif_dispatch.go b/pkg/services/notif_dispatch.go index 6ba156a..933ed6b 100644 --- a/pkg/services/notif_dispatch.go +++ b/pkg/services/notif_dispatch.go @@ -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("🎫 Nuevo ticket\nProyecto: %s\nCliente: %s\nTítulo: %s\n\n%s\n\n🔗 %s", - proyectoNombre, ticket.AutorNombre, ticket.Titulo, ticket.Descripcion, ticketURL) - sendTelegramAdmin(msg) - } + msg := fmt.Sprintf("🎫 Nuevo ticket\nProyecto: %s\nCliente: %s\nTítulo: %s\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("💬 Respuesta de cliente\nProyecto: %s\nCliente: %s\n\n%s\n\n🔗 %s", - proyectoNombre, ticket.AutorNombre, contenido, ticketURL) - sendTelegramAdmin(msg) - } + msg := fmt.Sprintf("💬 Respuesta de cliente\nProyecto: %s\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("💬 El equipo respondió tu ticket\nProyecto: %s\nTicket: %s\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("🧾 Nueva factura disponible\nNúmero: %s\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)) -} \ No newline at end of file diff --git a/resources/views/layouts/portal.html b/resources/views/layouts/portal.html index 2437d40..7a6e2db 100644 --- a/resources/views/layouts/portal.html +++ b/resources/views/layouts/portal.html @@ -20,84 +20,15 @@ -
+
- U + U Portal de Clientes - -
- -
- - -
-
- Notificaciones - -
-
- - -
-
-
- + +
diff --git a/resources/views/portal_usuarios.html b/resources/views/portal_usuarios.html index 2433f18..8afe904 100644 --- a/resources/views/portal_usuarios.html +++ b/resources/views/portal_usuarios.html @@ -1,6 +1,6 @@
-
+

Usuarios del Portal

Accesos del portal de clientes

@@ -11,15 +11,6 @@
- -
- - Los usuarios inician sesión en: - /portal/login - -
-
@@ -42,13 +33,13 @@
- + -