feat(notif): integrar notificaciones de tareas con NotifEventoConfig

- SeedNotifDefaults: poblar 10 eventos por defecto (tickets, servidores, tareas)
- notif_dispatch: NotificarTareaAsignada/Estado/Comentario ahora respetan
  canal_email, canal_telegram y canal_sistema de la config de notificaciones
- mail_service: añadir SendTareaAsignadaEmail y SendTareaNotifAdmin
- notif_config.html: agregar eventos tarea_asignada, tarea_estado, tarea_comentario
  al panel de configuración para que el admin pueda activar/desactivar canales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-06 22:06:40 -05:00
co-authored by Claude Sonnet 4.6
parent 107178aa97
commit 37648724f8
4 changed files with 170 additions and 21 deletions
+27 -2
View File
@@ -872,9 +872,34 @@ func SeedPortalClientes() {
log.Println("[SEED] Seed de Portal de Clientes completado.")
}
// SeedNotifDefaults es un stub idempotente para configuraciones de notificaciones por defecto.
// SeedNotifDefaults inserta configuraciones de notificación por defecto (idempotente).
// Solo crea registros que no existen; no sobreescribe cambios del admin.
func SeedNotifDefaults() {
log.Println("[SEED] SeedNotifDefaults: sin entradas por defecto definidas aún.")
db := app.Http.Database.DB
defaults := []models.NotifEventoConfig{
// Tickets
{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: "Portal user recibe cuando el admin responde"},
{Evento: "factura_subida", Destinatario: "portal_user", CanalEmail: true, CanalTelegram: false, CanalSistema: true, Descripcion: "Portal user recibe cuando se sube una factura"},
// Servidores
{Evento: "servidor_caido", Destinatario: "admin", CanalEmail: false, CanalTelegram: true, CanalSistema: true, Descripcion: "Admin recibe cuando un servidor deja de reportar"},
{Evento: "servidor_vence_pronto", Destinatario: "admin", CanalEmail: true, CanalTelegram: true, CanalSistema: true, Descripcion: "Admin recibe cuando un VPS está próximo a vencer"},
{Evento: "servidor_recurso_alto", Destinatario: "admin", CanalEmail: false, CanalTelegram: true, CanalSistema: true, Descripcion: "Admin recibe cuando CPU/RAM/Disco supera el umbral"},
// Tareas
{Evento: "tarea_asignada", Destinatario: "admin", CanalEmail: true, CanalTelegram: false, CanalSistema: true, Descripcion: "Notifica cuando se asigna una tarea a un usuario"},
{Evento: "tarea_estado", Destinatario: "admin", CanalEmail: false, CanalTelegram: false, CanalSistema: true, Descripcion: "Notifica cuando cambia el estado de una tarea"},
{Evento: "tarea_comentario", Destinatario: "admin", CanalEmail: false, CanalTelegram: false, CanalSistema: true, Descripcion: "Notifica cuando hay un nuevo comentario en una tarea"},
}
created := 0
for _, d := range defaults {
var existing models.NotifEventoConfig
if err := db.Where("evento = ? AND destinatario = ?", d.Evento, d.Destinatario).First(&existing).Error; err != nil {
db.Create(&d)
created++
}
}
log.Printf("[SEED] SeedNotifDefaults: %d nuevas configuraciones creadas.", created)
}
// SeedShield es un stub idempotente para la configuración de Shield.
+44
View File
@@ -220,6 +220,50 @@ func SendTicketRespuestaAdmin(adminEmail, proyectoNombre, autorNombre, titulo, r
}()
}
// SendTareaAsignadaEmail notifica al usuario asignado sobre una nueva tarea.
func SendTareaAsignadaEmail(toEmail, toName, tareaTitle, prioridad, descripcion, tareaURL string) {
subject := fmt.Sprintf("📋 Nueva tarea asignada: %s", tareaTitle)
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
<html><body style="font-family:Inter,sans-serif;background:#f1f5f9;padding:32px">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:16px;padding:32px;border:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
<div style="width:40px;height:40px;border-radius:50%%;background:#8eb02f;display:flex;align-items:center;justify-content:center;color:#fff;font-size:18px">📋</div>
<div><h2 style="margin:0;color:#1e293b;font-size:17px">Nueva tarea asignada</h2>
<p style="margin:0;color:#64748b;font-size:13px">Prioridad: <strong>%s</strong></p></div>
</div>
<p style="color:#334155;font-size:15px">Hola <strong>%s</strong>,</p>
<p style="color:#64748b;font-size:14px">Se te asignó la siguiente tarea:</p>
<div style="background:#f8fafc;border-radius:10px;padding:16px;margin:16px 0;border:1px solid #e2e8f0">
<p style="margin:0 0 8px;font-size:15px;font-weight:600;color:#1e293b">%s</p>
<p style="margin:0;font-size:13px;color:#64748b">%s</p>
</div>
<a href="%s" style="display:block;text-align:center;background:#8eb02f;color:#fff;padding:12px 24px;border-radius:10px;font-weight:600;text-decoration:none;font-size:15px">Ver tarea</a>
<p style="font-size:12px;color:#94a3b8;margin-top:24px;text-align:center">© U-site — Panel de administración</p>
</div></body></html>`, prioridad, toName, tareaTitle, descripcion, tareaURL)
go func() {
if err := app.Http.Mail.Send(toEmail, subject, htmlBody, ""); err != nil {
log.Printf("[Notif] Error enviando tarea asignada a %s: %v", toEmail, err)
}
}()
}
// SendTareaNotifAdmin notifica al admin sobre cambios en tareas (estado, comentarios).
func SendTareaNotifAdmin(adminEmail, titulo, cuerpo, tareaURL string) {
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
<html><body style="font-family:Inter,sans-serif;background:#f1f5f9;padding:32px">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:16px;padding:32px;border:1px solid #e2e8f0">
<h2 style="margin:0 0 12px;color:#1e293b;font-size:17px">%s</h2>
<p style="color:#334155;font-size:14px;white-space:pre-line">%s</p>
<a href="%s" style="display:block;text-align:center;background:#8eb02f;color:#fff;padding:12px 24px;border-radius:10px;font-weight:600;text-decoration:none;font-size:15px;margin-top:20px">Ver tablero de tareas</a>
<p style="font-size:12px;color:#94a3b8;margin-top:24px;text-align:center">© U-site — Panel de administración</p>
</div></body></html>`, titulo, cuerpo, tareaURL)
go func() {
if err := app.Http.Mail.Send(adminEmail, titulo, htmlBody, ""); err != nil {
log.Printf("[Notif] Error enviando notif tarea a admin: %v", err)
}
}()
}
// SendFacturaSubidaPortalUser notifica a un portal user que tiene una nueva factura disponible.
func SendFacturaSubidaPortalUser(toEmail, userName, clienteNombre, numero string, monto float64, moneda, portalURL string) {
go func() {
+95 -16
View File
@@ -260,22 +260,54 @@ func NotificarTareaAsignada(t *models.Tarea) {
if t == nil || t.AsignadoID == nil {
return
}
cfg := models.GetNotifConfig("tarea_asignada", "admin")
if cfg == nil {
return
}
asignado := ""
if t.Asignado != nil {
asignado = t.Asignado.Name
}
msg := fmt.Sprintf("📋 <b>Nueva tarea asignada</b>\n<b>%s</b>\nAsignado a: %s\nPrioridad: %s\n\n%s",
escapeTelegramHTML(t.Titulo),
escapeTelegramHTML(asignado),
escapeTelegramHTML(t.Prioridad),
escapeTelegramHTML(t.Descripcion))
sendTelegramAdmin(msg)
titulo := fmt.Sprintf("📋 Nueva tarea asignada: %s", t.Titulo)
cuerpo := fmt.Sprintf("Asignado a: %s | Prioridad: %s\n%s", asignado, t.Prioridad, t.Descripcion)
tareaPath := "/app/tareas"
if cfg.CanalSistema {
_ = models.CreateSistemaNotif(&models.SistemaNotificacion{
TipoUsuario: "admin",
UsuarioID: 0,
Titulo: titulo,
Cuerpo: cuerpo,
Url: tareaPath,
Icono: "📋",
})
}
if cfg.CanalEmail {
tareaURL := absAppURL(tareaPath)
if t.Asignado != nil && t.Asignado.Email != "" {
go SendTareaAsignadaEmail(t.Asignado.Email, t.Asignado.Name, t.Titulo, t.Prioridad, t.Descripcion, tareaURL)
} else if adminEmail := getAdminEmail(); adminEmail != "" {
go SendTareaNotifAdmin(adminEmail, titulo, cuerpo, tareaURL)
}
}
if cfg.CanalTelegram {
msg := fmt.Sprintf("📋 <b>Nueva tarea asignada</b>\n<b>%s</b>\nAsignado a: %s\nPrioridad: %s\n\n%s",
escapeTelegramHTML(t.Titulo),
escapeTelegramHTML(asignado),
escapeTelegramHTML(t.Prioridad),
escapeTelegramHTML(t.Descripcion))
sendTelegramAdmin(msg)
}
}
func NotificarTareaEstado(t *models.Tarea) {
if t == nil {
return
}
cfg := models.GetNotifConfig("tarea_estado", "admin")
if cfg == nil {
return
}
etiquetas := map[string]string{
"por_hacer": "📥 Por hacer",
"en_progreso": "🔄 En progreso",
@@ -287,24 +319,71 @@ func NotificarTareaEstado(t *models.Tarea) {
label = t.Estado
}
asignado := ""
asignadoSuffix := ""
if t.Asignado != nil {
asignado = "\nAsignado: " + escapeTelegramHTML(t.Asignado.Name)
asignado = t.Asignado.Name
asignadoSuffix = "\nAsignado: " + asignado
}
titulo := fmt.Sprintf("🔀 Tarea → %s: %s", label, t.Titulo)
cuerpo := fmt.Sprintf("Estado: %s%s", label, asignadoSuffix)
tareaPath := "/app/tareas"
if cfg.CanalSistema {
_ = models.CreateSistemaNotif(&models.SistemaNotificacion{
TipoUsuario: "admin",
UsuarioID: 0,
Titulo: titulo,
Cuerpo: cuerpo,
Url: tareaPath,
Icono: "🔀",
})
}
if cfg.CanalEmail {
if adminEmail := getAdminEmail(); adminEmail != "" {
go SendTareaNotifAdmin(adminEmail, titulo, cuerpo, absAppURL(tareaPath))
}
}
if cfg.CanalTelegram {
msg := fmt.Sprintf("🔀 <b>Tarea movida → %s</b>\n<b>%s</b>%s",
label,
escapeTelegramHTML(t.Titulo),
escapeTelegramHTML(asignadoSuffix))
sendTelegramAdmin(msg)
}
msg := fmt.Sprintf("🔀 <b>Tarea movida → %s</b>\n<b>%s</b>%s",
label,
escapeTelegramHTML(t.Titulo),
asignado)
sendTelegramAdmin(msg)
}
func NotificarTareaComentario(t *models.Tarea, contenido string) {
if t == nil {
return
}
msg := fmt.Sprintf("💬 <b>Nuevo comentario en tarea</b>\n<b>%s</b>\n\n%s",
escapeTelegramHTML(t.Titulo),
escapeTelegramHTML(contenido))
sendTelegramAdmin(msg)
cfg := models.GetNotifConfig("tarea_comentario", "admin")
if cfg == nil {
return
}
titulo := fmt.Sprintf("💬 Nuevo comentario en tarea: %s", t.Titulo)
tareaPath := "/app/tareas"
if cfg.CanalSistema {
_ = models.CreateSistemaNotif(&models.SistemaNotificacion{
TipoUsuario: "admin",
UsuarioID: 0,
Titulo: titulo,
Cuerpo: contenido,
Url: tareaPath,
Icono: "💬",
})
}
if cfg.CanalEmail {
if adminEmail := getAdminEmail(); adminEmail != "" {
go SendTareaNotifAdmin(adminEmail, titulo, contenido, absAppURL(tareaPath))
}
}
if cfg.CanalTelegram {
msg := fmt.Sprintf("💬 <b>Nuevo comentario en tarea</b>\n<b>%s</b>\n\n%s",
escapeTelegramHTML(t.Titulo),
escapeTelegramHTML(contenido))
sendTelegramAdmin(msg)
}
}
func sendTelegramAdmin(mensaje string) {
+4 -3
View File
@@ -185,9 +185,10 @@ const EVENTOS = [
{ evento: 'servidor_caido', destinatario: 'admin', label: 'Servidor sin señal (agente caído)', icon: '🔴' },
{ evento: 'servidor_vence_pronto', destinatario: 'admin', label: 'VPS próximo a vencer', icon: '📅' },
{ evento: 'servidor_recurso_alto', destinatario: 'admin', label: 'Recurso al límite (CPU/RAM/Disco)', icon: '⚠️' },
// Próximos eventos (deshabilitados por ahora):
// { evento: 'factura_emitida', destinatario: 'portal_user', label: 'Factura emitida', icon: '🧾' },
// { evento: 'avance_publicado', destinatario: 'portal_user', label: 'Avance publicado', icon: '📦' },
// Tareas
{ evento: 'tarea_asignada', destinatario: 'admin', label: 'Tarea asignada', icon: '📋' },
{ evento: 'tarea_estado', destinatario: 'admin', label: 'Tarea cambio de estado', icon: '🔀' },
{ evento: 'tarea_comentario', destinatario: 'admin', label: 'Comentario en tarea', icon: '💬' },
];
function notifConfig() {