diff --git a/main.go b/main.go index ab4409e..0127414 100755 --- a/main.go +++ b/main.go @@ -74,6 +74,7 @@ func main() { migrations.SeedPlantillasBase() migrations.SeedTelegram() migrations.SeedPortalClientes() + migrations.SeedNotifDefaults() // Iniciar cron de vencimientos services.IniciarCron() defer services.DetenerCron() diff --git a/migrations/migrate.go b/migrations/migrate.go index f19dbfb..992cf29 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -81,8 +81,9 @@ func Migrate() { &models.ProyectoEntregable{}, &models.ProyectoTicket{}, &models.TicketMensaje{}, - &models.Factura{}, - ); err != nil { + &models.Factura{}, // Sistema de notificaciones + &models.NotifEventoConfig{}, + &models.SistemaNotificacion{}, ); err != nil { log.Fatalf("Error during main migration: %v", err) } @@ -850,3 +851,24 @@ func SeedPortalClientes() { } 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"}, + } + 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.") +} diff --git a/pkg/models/notif_config.go b/pkg/models/notif_config.go new file mode 100644 index 0000000..9b390ed --- /dev/null +++ b/pkg/models/notif_config.go @@ -0,0 +1,110 @@ +package models + +import ( + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ─── NotifEventoConfig ──────────────────────────────────────────────────────── +// Define qué canales activar para cada combinación evento × destinatario. +// +// Eventos disponibles (extensibles): +// ticket_nuevo → admin recibe cuando el cliente abre un ticket +// ticket_respuesta_cliente → admin recibe cuando el cliente responde +// ticket_respuesta_admin → portal_user recibe cuando admin responde +// factura_emitida → portal_user recibe cuando se emite factura +// avance_publicado → portal_user recibe cuando se publica avance +// +// Destinatarios: "admin" | "portal_user" +type NotifEventoConfig struct { + gorm.Model + Evento string `json:"evento" gorm:"column:evento;uniqueIndex:uidx_notif_ev_dest"` + Destinatario string `json:"destinatario" gorm:"column:destinatario;uniqueIndex:uidx_notif_ev_dest"` + CanalEmail bool `json:"canal_email" gorm:"column:canal_email;default:true"` + CanalTelegram bool `json:"canal_telegram" gorm:"column:canal_telegram;default:false"` + CanalSistema bool `json:"canal_sistema" gorm:"column:canal_sistema;default:true"` + Descripcion string `json:"descripcion" gorm:"column:descripcion"` +} + +func (NotifEventoConfig) TableName() string { return "notif_evento_configs" } + +// ─── SistemaNotificacion ────────────────────────────────────────────────────── +// Notificaciones in-app visibles en el badge del header. +// tipo_usuario = "admin" → visible para todos los admins (usuario_id = 0) +// tipo_usuario = "portal_user" → visible solo para ese portal user +type SistemaNotificacion struct { + gorm.Model + TipoUsuario string `json:"tipo_usuario" gorm:"column:tipo_usuario;index"` + UsuarioID uint `json:"usuario_id" gorm:"column:usuario_id;index"` + Titulo string `json:"titulo" gorm:"column:titulo"` + Cuerpo string `json:"cuerpo" gorm:"column:cuerpo;type:text"` + Leida bool `json:"leida" gorm:"column:leida;default:false"` + Url string `json:"url" gorm:"column:url"` + Icono string `json:"icono" gorm:"column:icono"` // emoji: 🎫 🧾 📦 +} + +func (SistemaNotificacion) TableName() string { return "sistema_notificaciones" } + +// ─── CRUD NotifEventoConfig ─────────────────────────────────────────────────── + +func GetAllNotifConfigs() ([]NotifEventoConfig, error) { + var items []NotifEventoConfig + err := app.Http.Database.DB.Order("evento, destinatario").Find(&items).Error + return items, err +} + +func GetNotifConfig(evento, destinatario string) *NotifEventoConfig { + var item NotifEventoConfig + if err := app.Http.Database.DB. + Where("evento = ? AND destinatario = ?", evento, destinatario). + First(&item).Error; err != nil { + return nil + } + return &item +} + +// UpsertNotifConfig inserta o actualiza los canales para un evento × destinatario. +func UpsertNotifConfig(cfg *NotifEventoConfig) error { + return app.Http.Database.DB. + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "evento"}, {Name: "destinatario"}}, + DoUpdates: clause.AssignmentColumns([]string{"canal_email", "canal_telegram", "canal_sistema"}), + }). + Create(cfg).Error +} + +// ─── CRUD SistemaNotificacion ───────────────────────────────────────────────── + +func CreateSistemaNotif(n *SistemaNotificacion) error { + return app.Http.Database.DB.Create(n).Error +} + +func GetSistemaNotifs(tipoUsuario string, usuarioID uint, soloNoLeidas bool) ([]SistemaNotificacion, error) { + var items []SistemaNotificacion + db := app.Http.Database.DB.Where("tipo_usuario = ? AND (usuario_id = ? OR usuario_id = 0)", tipoUsuario, usuarioID) + if soloNoLeidas { + db = db.Where("leida = false") + } + err := db.Order("created_at DESC").Limit(50).Find(&items).Error + return items, err +} + +func CountUnreadNotifs(tipoUsuario string, usuarioID uint) int64 { + var count int64 + app.Http.Database.DB.Model(&SistemaNotificacion{}). + Where("tipo_usuario = ? AND (usuario_id = ? OR usuario_id = 0) AND leida = false", tipoUsuario, usuarioID). + Count(&count) + return count +} + +func MarcarNotifLeida(id uint) error { + return app.Http.Database.DB.Model(&SistemaNotificacion{}). + Where("id = ?", id).Update("leida", true).Error +} + +func MarcarTodasLeidas(tipoUsuario string, usuarioID uint) error { + return app.Http.Database.DB.Model(&SistemaNotificacion{}). + Where("tipo_usuario = ? AND (usuario_id = ? OR usuario_id = 0)", tipoUsuario, usuarioID). + Update("leida", true).Error +} diff --git a/pkg/models/portal_user.go b/pkg/models/portal_user.go index 6f1070b..34b61ad 100644 --- a/pkg/models/portal_user.go +++ b/pkg/models/portal_user.go @@ -22,9 +22,12 @@ type PortalUser struct { 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"` - PortalAccesos []PortalAcceso `json:"portal_accesos" gorm:"foreignKey:PortalUserID"` + Activo bool `json:"activo" gorm:"column:activo;default:true"` + Notas string `json:"notas" gorm:"column:notas;type:text"` + TelegramChatID string `json:"telegram_chat_id" gorm:"column:telegram_chat_id"` + Telefono string `json:"telefono" gorm:"column:telefono"` + Pais string `json:"pais" gorm:"column:pais"` + PortalAccesos []PortalAcceso `json:"portal_accesos" gorm:"foreignKey:PortalUserID"` } func (PortalUser) TableName() string { return "portal_users" } @@ -78,8 +81,11 @@ func UpdatePortalUser(u *PortalUser) error { "cliente_id": u.ClienteID, "rol": u.Rol, "role_id": u.RoleID, - "activo": u.Activo, - "notas": u.Notas, + "activo": u.Activo, + "notas": u.Notas, + "telegram_chat_id": u.TelegramChatID, + "telefono": u.Telefono, + "pais": u.Pais, }).Error } diff --git a/pkg/services/mail_service.go b/pkg/services/mail_service.go index 80108cd..555175e 100755 --- a/pkg/services/mail_service.go +++ b/pkg/services/mail_service.go @@ -107,3 +107,72 @@ func SendPortalCredentialsEmail(email, nombre, password string) { } }() } + +// ─── Notificaciones de Tickets ──────────────────────────────────────────────── + +// SendTicketNuevoAdmin notifica al admin que el cliente abrió un ticket. +func SendTicketNuevoAdmin(adminEmail, proyectoNombre, autorNombre, titulo, descripcion, ticketURL string) { + subject := fmt.Sprintf("🎫 Nuevo ticket: %s", titulo) + htmlBody := fmt.Sprintf(` + +
+
+
🎫
+

Nuevo ticket de soporte

+

Proyecto: %s

+
+
+

Cliente: %s

+

Título: %s

+

Descripción: %s

+
+ Ver ticket +
`, proyectoNombre, autorNombre, titulo, descripcion, ticketURL) + go func() { + if err := app.Http.Mail.Send(adminEmail, subject, htmlBody, ""); err != nil { + log.Printf("[Notif] Error enviando ticket nuevo a admin: %v", err) + } + }() +} + +// SendTicketRespuestaPortalUser notifica al portal user que el admin respondió. +func SendTicketRespuestaPortalUser(userEmail, userName, proyectoNombre, titulo, respuesta, ticketURL string) { + subject := fmt.Sprintf("💬 Respuesta en tu ticket: %s", titulo) + htmlBody := fmt.Sprintf(` + +
+
+
💬
+

Hay una respuesta en tu ticket

+

Proyecto: %s

+
+

Hola %s,

+

El equipo respondió tu ticket "%s":

+
%s
+ Ver mi ticket +
`, proyectoNombre, userName, titulo, respuesta, ticketURL) + go func() { + if err := app.Http.Mail.Send(userEmail, subject, htmlBody, ""); err != nil { + log.Printf("[Notif] Error enviando respuesta ticket a %s: %v", userEmail, err) + } + }() +} + +// SendTicketRespuestaAdmin notifica al admin que el portal user respondió. +func SendTicketRespuestaAdmin(adminEmail, proyectoNombre, autorNombre, titulo, respuesta, ticketURL string) { + subject := fmt.Sprintf("💬 Respuesta de cliente en ticket: %s", titulo) + htmlBody := fmt.Sprintf(` + +
+

💬 Respuesta de cliente

+

Proyecto: %s | Cliente: %s

+

En el ticket "%s":

+
%s
+ Ver ticket +
`, proyectoNombre, autorNombre, titulo, respuesta, ticketURL) + go func() { + if err := app.Http.Mail.Send(adminEmail, subject, htmlBody, ""); err != nil { + log.Printf("[Notif] Error enviando respuesta cliente a admin: %v", err) + } + }() +} diff --git a/pkg/services/notif_dispatch.go b/pkg/services/notif_dispatch.go new file mode 100644 index 0000000..eb5a732 --- /dev/null +++ b/pkg/services/notif_dispatch.go @@ -0,0 +1,162 @@ +package services + +import ( + "fmt" + "log" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// ─── DispatchTicketNuevo ────────────────────────────────────────────────────── +// Llamar cuando el portal user crea un nuevo ticket. +// Notifica al ADMIN según su configuración de canales. +func DispatchTicketNuevo(ticket *models.ProyectoTicket, portalUser *models.PortalUser, proyectoNombre string) { + cfg := models.GetNotifConfig("ticket_nuevo", "admin") + if cfg == nil { + return + } + ticketURL := fmt.Sprintf("%s/app/tickets", app.Http.Server.Url) + 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) + } +} + +// ─── DispatchTicketRespuestaCliente ────────────────────────────────────────── +// Llamar 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) { + cfg := models.GetNotifConfig("ticket_respuesta_cliente", "admin") + if cfg == nil { + return + } + ticketURL := fmt.Sprintf("%s/app/tickets", app.Http.Server.Url) + 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) + } +} + +// ─── DispatchTicketRespuestaAdmin ───────────────────────────────────────────── +// Llamar 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) { + cfg := models.GetNotifConfig("ticket_respuesta_admin", "portal_user") + if cfg == 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("%s/portal/proyecto/%s", app.Http.Server.Url, 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) + } + } +} + +// ─── helpers internos ───────────────────────────────────────────────────────── + +// getAdminEmail devuelve el from_address del SMTP activo como email del admin. +func getAdminEmail() string { + cfg, err := models.GetSmtpConfig() + if err != nil || cfg == nil { + return "" + } + return cfg.FromAddress +} + +// getAdminBotToken devuelve el token del primer TelegramConfig activo. +func getAdminBotToken() string { + configs, err := models.GetAllTelegramConfigs() + if err != nil { + return "" + } + for _, c := range configs { + if c.Activo { + return c.BotToken + } + } + return "" +} + +// sendTelegramAdmin envía un mensaje usando el primer TelegramConfig activo. +func sendTelegramAdmin(mensaje string) { + configs, err := models.GetAllTelegramConfigs() + if err != nil { + log.Printf("[Notif] Error obteniendo configs telegram: %v", err) + return + } + for _, cfg := range configs { + if !cfg.Activo { + continue + } + ts := &TelegramService{BotToken: cfg.BotToken} + if err := ts.SendMessage(cfg.ChatID, mensaje); err != nil { + log.Printf("[Notif] Error enviando telegram admin (cfg %d): %v", cfg.ID, err) + } + } +} diff --git a/pkg/services/telegram_service.go b/pkg/services/telegram_service.go index 98efdfd..7f6812b 100755 --- a/pkg/services/telegram_service.go +++ b/pkg/services/telegram_service.go @@ -56,3 +56,9 @@ func (ts *TelegramService) SendMessage(chatID interface{}, message string) error return nil } + +// SendMessageWithToken envía un mensaje usando un bot token explícito (útil para notificar a usuarios con su propio chat_id). +func (ts *TelegramService) SendMessageWithToken(chatID interface{}, message, botToken string) error { + svc := &TelegramService{BotToken: botToken} + return svc.SendMessage(chatID, message) +} diff --git a/resources/views/notif_config.html b/resources/views/notif_config.html new file mode 100644 index 0000000..b491e65 --- /dev/null +++ b/resources/views/notif_config.html @@ -0,0 +1,169 @@ +
+ +
+

Configuración de notificaciones

+

+ Define por qué canales se envían las alertas a cada destinatario. Más eventos se agregarán conforme el sistema crezca. +

+
+ + +
+ + + + + + + + + + + + + + +
EventoDestinatario + 📧 Email + + ✈️ Telegram + + 🔔 Sistema + Guardar
+
+ + +
+

📱 Telegram para clientes del portal

+

+ Para notificar a un cliente por Telegram, el cliente debe abrir una conversación con el bot y compartirte su Chat ID. + Luego ve a Portal Usuarios y agrega el Chat ID en su perfil. + El bot a usar es el mismo configurado en Configuración Telegram. +

+
+ + +
+
+ +
+ + diff --git a/resources/views/partials/header.html b/resources/views/partials/header.html index f799aef..ffa77bc 100755 --- a/resources/views/partials/header.html +++ b/resources/views/partials/header.html @@ -3,8 +3,158 @@ open: false, initial: '{{ .user.NombreUsuario }}'.charAt(0).toUpperCase() || 'U', userName: '{{ .user.NombreUsuario }}', - roleName: '{{ .user.Role.Name }}' - }"> + roleName: '{{ .user.Role.Name }}', + bellOpen: false, + notifs: [], + unread: 0, + async loadNotifs() { + try { + const r = await axios.get('/app/mis-notifs?no_leidas=0'); + this.notifs = r.data.items || []; + this.unread = r.data.unread || 0; + } catch {} + }, + async markRead(id) { + await axios.put('/app/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('/app/mis-notifs/marcar-todas'); + this.notifs.forEach(n => n.leida = true); + this.unread = 0; + } + }" + x-init="loadNotifs(); setInterval(() => loadNotifs(), 30000)"> + + +
+
+ +
+ + +
+ + +
+ + + +
+ +
+ Notificaciones + +
+ +
+ + +
+ + +
+
+ + + + + +
+ + + + +
+
+
diff --git a/rest/controllers/notif_config_controller.go b/rest/controllers/notif_config_controller.go new file mode 100644 index 0000000..53b6617 --- /dev/null +++ b/rest/controllers/notif_config_controller.go @@ -0,0 +1,65 @@ +package controllers + +import ( + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// ─── Configuración de canales de notificación ───────────────────────────────── + +func NotifConfigIndex(c *fiber.Ctx) error { + return c.Render("notif_config", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func GetNotifConfigs(c *fiber.Ctx) error { + items, err := models.GetAllNotifConfigs() + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(items) +} + +func SaveNotifConfig(c *fiber.Ctx) error { + var cfg models.NotifEventoConfig + if err := c.BodyParser(&cfg); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if cfg.Evento == "" || cfg.Destinatario == "" { + return c.Status(400).JSON(fiber.Map{"error": "evento y destinatario son requeridos"}) + } + if err := models.UpsertNotifConfig(&cfg); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +// ─── Notificaciones in-app (sistema) ───────────────────────────────────────── + +// GetMisNotifs devuelve notificaciones del admin autenticado (tipo_usuario=admin, usuario_id=0). +func GetMisNotifs(c *fiber.Ctx) error { + soloNoLeidas := c.Query("no_leidas") == "1" + items, err := models.GetSistemaNotifs("admin", 0, soloNoLeidas) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + count := models.CountUnreadNotifs("admin", 0) + return c.JSON(fiber.Map{"items": items, "unread": count}) +} + +func MarcarNotifLeida(c *fiber.Ctx) error { + 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 MarcarTodasLeidas(c *fiber.Ctx) error { + if err := models.MarcarTodasLeidas("admin", 0); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} diff --git a/rest/controllers/portal_controller.go b/rest/controllers/portal_controller.go index e1630c8..0a3f852 100644 --- a/rest/controllers/portal_controller.go +++ b/rest/controllers/portal_controller.go @@ -9,6 +9,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/auth" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" "github.com/sujit-baniya/fiber-boilerplate/rest/middlewares" ) @@ -228,6 +229,7 @@ func PortalCrearTicket(c *fiber.Ctx) error { if err := models.CreateProyectoTicket(t); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } + go services.DispatchTicketNuevo(t, u, proy.Nombre) return c.Status(201).JSON(t) } @@ -262,6 +264,7 @@ func PortalResponderTicket(c *fiber.Ctx) error { if err := models.CreateTicketMensaje(msg); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } + go services.DispatchTicketRespuestaCliente(ticket, req.Contenido, ticket.Proyecto.Nombre) return c.Status(201).JSON(msg) } diff --git a/rest/controllers/proyecto_controller.go b/rest/controllers/proyecto_controller.go index f268de6..1aaa20a 100644 --- a/rest/controllers/proyecto_controller.go +++ b/rest/controllers/proyecto_controller.go @@ -12,6 +12,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" ) // ─── Admin pages ────────────────────────────────────────────────────────────── @@ -458,6 +459,11 @@ func AdminResponderTicket(c *fiber.Ctx) error { } // Cambiar estado a en_progreso si estaba abierto _ = models.UpdateTicketEstado(uint(ticketID), "en_progreso") + // Notificar al portal user + ticket, _ := models.GetTicketByID(uint(ticketID)) + if ticket != nil { + go services.DispatchTicketRespuestaAdmin(ticket, req.Contenido, ticket.Proyecto.Nombre) + } return c.Status(201).JSON(msg) } diff --git a/rest/routes/user.go b/rest/routes/user.go index 2f223d9..fbeea4f 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -253,6 +253,16 @@ func UserRoutes(app fiber.Router) { protected.Put("/tickets/:ticketID/estado", controllers.UpdateTicketEstadoAdmin) protected.Post("/tickets/:ticketID/mensaje", controllers.AdminResponderTicket) + // Configuración de notificaciones + protected.Get("/notif-config", middlewares.MenuMiddleware, controllers.NotifConfigIndex) + protected.Get("/notif-config/data", controllers.GetNotifConfigs) + protected.Post("/notif-config", controllers.SaveNotifConfig) + + // Notificaciones in-app del admin (bell) + protected.Get("/mis-notifs", controllers.GetMisNotifs) + protected.Put("/mis-notifs/:id/leida", controllers.MarcarNotifLeida) + protected.Post("/mis-notifs/marcar-todas", controllers.MarcarTodasLeidas) + protected.Get("/portal-usuarios", middlewares.MenuMiddleware, controllers.PortalUsuariosIndex) protected.Get("/loadportalusuarios", controllers.LoadPortalUsuarios) protected.Post("/portal-usuarios", controllers.CreatePortalUsuario)