Files
soft_usite/pkg/models/notif_config.go
T
Lizandro GuarnizoandClaude Opus 5 b2e2e83fe8 feat(umind): el agente programa recordatorios, y solo para su dueño
"Avisame el 15 de marzo que vence la póliza de Acme, y todos los años" queda
programado desde la conversación, visible y cancelable en el panel. Tres tools:
programar, listar, cancelar.

El aviso vuelve por donde se pidió — Telegram a ese chat, correo a esa casilla
— o al dueño del espacio por correo y campanita. Nunca a una dirección que
dicte la conversación: eso sería un cañón de spam con destinatario libre.

Y las tools de aviso solo se le OFRECEN al modelo cuando la sesión es interna:
el chat de prueba del panel (que corre autenticado) o un canal marcado como
línea privada del dueño. En el widget público escribe cualquiera, y cualquiera
no puede programarle recordatorios ni gastarle el plan a otro. El gate está en
dos capas: la tool no se declara, y si igual la pide, el ejecutor la rechaza.

Tres detalles que se pagan una sola vez:
- El cron corre en memoria del proceso, sin lock distribuido: con dos
  instancias cada aviso saldría dos veces. El reclamo es un UPDATE condicional
  — la base ya es el árbitro, no hace falta traer otro.
- Si el servidor estuvo caído, una repetición diaria se saltea los ciclos
  perdidos en vez de disparar diez avisos viejos de golpe.
- Un fallo de SMTP devuelve el aviso a pendiente: una caída de correo no puede
  perder un vencimiento de póliza.

Una fecha sin hora se entrega a las 9, no a medianoche, que es cuando nadie
mira el teléfono.

De paso: los archivos de los espacios uMind nunca se sirven por el estático de
/uploads. El guard genérico solo sabe si hay sesión de panel, no de quién es el
archivo — salen por su endpoint, que sí valida propiedad. Cerrado por
construcción y no por acordarse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:32:48 -05:00

190 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package models
import (
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// ─── 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 {
db := app.Http.Database.DB
result := db.Model(&NotifEventoConfig{}).
Where("evento = ? AND destinatario = ?", cfg.Evento, cfg.Destinatario).
Updates(map[string]interface{}{
"canal_email": cfg.CanalEmail,
"canal_telegram": cfg.CanalTelegram,
"canal_sistema": cfg.CanalSistema,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return db.Create(cfg).Error
}
return nil
}
// ─── 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
}
func CountUnreadFacturaNotifs(usuarioID uint) int64 {
var count int64
app.Http.Database.DB.Model(&SistemaNotificacion{}).
Where("tipo_usuario = 'portal_user' AND usuario_id = ? AND icono = '🧾' AND leida = false", usuarioID).
Count(&count)
return count
}
func MarcarNotifsFacturasLeidas(usuarioID uint) error {
return app.Http.Database.DB.Model(&SistemaNotificacion{}).
Where("tipo_usuario = 'portal_user' AND usuario_id = ? AND icono = '🧾'", usuarioID).
Update("leida", true).Error
}
// ─── ServidorAlertaUmbral ─────────────────────────────────────────────────────
// Configuración global de umbrales para alertas de servidores.
// Solo existe un registro (singleton, ID = 1).
type ServidorAlertaUmbral struct {
gorm.Model
Activo bool `json:"activo" gorm:"column:activo;default:true"`
MinutosSinPing int `json:"minutos_sin_ping" gorm:"column:minutos_sin_ping;default:10"`
UmbralCPU int `json:"umbral_cpu" gorm:"column:umbral_cpu;default:90"`
UmbralRAM int `json:"umbral_ram" gorm:"column:umbral_ram;default:90"`
UmbralDisco int `json:"umbral_disco" gorm:"column:umbral_disco;default:90"`
DiasAnteVencimiento int `json:"dias_ante_vencimiento" gorm:"column:dias_ante_vencimiento;default:7"`
}
func (ServidorAlertaUmbral) TableName() string { return "servidor_alerta_umbral" }
// GetServidorAlertaUmbral obtiene el singleton de umbrales; crea uno con defaults si no existe.
func GetServidorAlertaUmbral() *ServidorAlertaUmbral {
var cfg ServidorAlertaUmbral
db := app.Http.Database.DB
if err := db.First(&cfg).Error; err != nil {
cfg = ServidorAlertaUmbral{Activo: true, MinutosSinPing: 10, UmbralCPU: 90, UmbralRAM: 90, UmbralDisco: 90, DiasAnteVencimiento: 7}
db.Create(&cfg)
}
return &cfg
}
// SaveServidorAlertaUmbral guarda el singleton.
func SaveServidorAlertaUmbral(cfg *ServidorAlertaUmbral) error {
db := app.Http.Database.DB
var existing ServidorAlertaUmbral
if err := db.First(&existing).Error; err != nil {
return db.Create(cfg).Error
}
return db.Model(&existing).Updates(map[string]interface{}{
"activo": cfg.Activo,
"minutos_sin_ping": cfg.MinutosSinPing,
"umbral_cpu": cfg.UmbralCPU,
"umbral_ram": cfg.UmbralRAM,
"umbral_disco": cfg.UmbralDisco,
"dias_ante_vencimiento": cfg.DiasAnteVencimiento,
}).Error
}
// YaExisteAlertaServidor evita spam: true si ya se creó una notificación con el
// mismo título para este servidor en las últimas `horas` horas.
func YaExisteAlertaServidor(titulo string, horas int) bool {
var count int64
app.Http.Database.DB.Model(&SistemaNotificacion{}).
Where("titulo = ? AND created_at > ?", titulo, time.Now().Add(-time.Duration(horas)*time.Hour)).
Count(&count)
return count > 0
}