Files
soft_usite/pkg/services/cron_service.go
T
Lizandro GuarnizoandClaude Opus 5 544cba6d34 feat(umind): repositorio de archivos del espacio y bandeja de aprobación
Dos piezas que se necesitan mutuamente.

ARCHIVOS — a nivel espacio y no de agente, porque los papeles son del negocio:
si mañana crea un segundo agente, sus contratos no se mudan. Subir, descargar,
borrar, cuota por plan, y un botón "usar como conocimiento" que lo manda por la
ingesta que ya existe — un clic, no volver a subir el mismo PDF.

Nunca se sirven por el estático: salen por su endpoint, que valida el tenant.
La cuota se mira antes de escribir, porque rechazar después de copiar 25 MB al
disco es cobrarle el espacio igual.

PENDIENTES — no es un motor de workflows, es una tabla y una regla: lo que sale
hacia afuera pedido por alguien que no es el dueño no se ejecuta, se encola.

El eje es quién está del otro lado, no qué tan peligrosa suena la acción. En el
widget público escribe cualquiera: ahí toda acción espera. En un canal interno
el dueño ya autorizó al escribirlo, y mandarlo a aprobar su propio pedido sería
fricción sin ninguna seguridad a cambio. (El plan decía aprobar siempre el
envío de correo; esto es más flojo a propósito y la propiedad que importa se
mantiene entera: desde un canal público no sale nada sin una persona.)

Cuatro decisiones que valen más que el esquema:
- Se guarda el payload EXACTO y se ejecuta eso. Aprobar no vuelve a llamar al
  modelo: si regenerara, aprobarías una cosa y saldría otra, y la diferencia
  aparecería recién en la mano del cliente. Hay un test que lo vigila.
- Dos personas mirando la misma bandeja pueden aprobar a la vez; el reclamo es
  un UPDATE condicional, así la cotización no sale dos veces.
- Vencen a los 7 días. Una cotización aprobada tres semanas tarde llega con
  precios de otro mes: es peor que ninguna.
- Al cliente nunca se le dice "rechazado" ni se le menciona una aprobación
  interna — se le dice que le responde alguien del equipo. También con test.

El correo de aviso lleva un enlace al panel CON login: un enlace que ejecuta
algo irreversible sin autenticar es un enlace que reenviado por error firma.

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

531 lines
16 KiB
Go

package services
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sort"
"strconv"
"time"
"github.com/robfig/cron/v3"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
type cronProcInfo struct {
Nombre string `json:"nombre"`
CPU float64 `json:"cpu"`
RAMMB float64 `json:"ram_mb"`
}
var cronScheduler *cron.Cron
// IniciarCron arranca el scheduler de tareas. Llamar desde app.go o main.go.
func IniciarCron() {
cronScheduler = cron.New()
// Vencimientos próximos — todos los días a las 8:00 AM
if _, err := cronScheduler.AddFunc("0 8 * * *", ProcesarVencimientosProximos); err != nil {
log.Printf("[CRON] Error registrando tarea vencimientos_proximo: %v", err)
return
}
// Contratos ya vencidos — todos los días a las 9:00 AM
if _, err := cronScheduler.AddFunc("0 9 * * *", ProcesarYaVencidos); err != nil {
log.Printf("[CRON] Error registrando tarea ya_vencido: %v", err)
return
}
// Polling Bold — cada 15 minutos para detectar pagos sin webhook
if _, err := cronScheduler.AddFunc("*/15 * * * *", VerificarPagosBoldPendientes); err != nil {
log.Printf("[CRON] Error registrando tarea bold_polling: %v", err)
return
}
// Salud de servidores — cada 5 minutos
if _, err := cronScheduler.AddFunc("*/5 * * * *", VerificarSaludServidores); err != nil {
log.Printf("[CRON] Error registrando tarea salud_servidores: %v", err)
return
}
// Agentes de uMind creados pero sin base de conocimiento — una vez al día
// a las 10:00. Es el punto donde más clientes se quedan a mitad de camino,
// y se detecta solo.
if _, err := cronScheduler.AddFunc("0 10 * * *", AvisarAgentesSinConocimiento); err != nil {
log.Printf("[CRON] Error registrando tarea umind_activacion: %v", err)
return
}
// Purga de historial de métricas — cada noche a las 3 AM (retención 7 días)
if _, err := cronScheduler.AddFunc("0 3 * * *", func() {
if err := models.PurgarMetricasHistory(7); err != nil {
log.Printf("[CRON] Error purgando historial métricas: %v", err)
} else {
log.Println("[CRON] Historial de métricas purgado (>7 días)")
}
if err := models.PurgarUrlMonitorLogs(7); err != nil {
log.Printf("[CRON] Error purgando logs URL monitor: %v", err)
}
}); err != nil {
log.Printf("[CRON] Error registrando tarea purga_metricas: %v", err)
return
}
// Monitor de URLs — cada minuto
if _, err := cronScheduler.AddFunc("* * * * *", VerificarUrlMonitors); err != nil {
log.Printf("[CRON] Error registrando tarea url_monitor: %v", err)
return
}
// Conocimiento de los agentes que se actualiza solo — 4 AM, cuando nadie
// está mirando: recrawlear varios sitios no es gratis.
if _, err := cronScheduler.AddFunc("0 4 * * *", RefrescarConocimientoUmind); err != nil {
log.Printf("[CRON] Error registrando tarea refresco_conocimiento: %v", err)
return
}
// Los pendientes que nadie miró vencen solos.
if _, err := cronScheduler.AddFunc("0 5 * * *", func() { models.VencerAccionesViejas() }); err != nil {
log.Printf("[CRON] Error registrando tarea vencer_acciones: %v", err)
return
}
// Recordatorios programados por los propios clientes.
if _, err := cronScheduler.AddFunc("* * * * *", DespacharAvisos); err != nil {
log.Printf("[CRON] Error registrando tarea avisos_umind: %v", err)
return
}
// Canales de correo de los agentes — corre cada minuto, pero cada casilla
// se revisa según el intervalo que eligió su dueño.
if _, err := cronScheduler.AddFunc("* * * * *", RevisarCanalesCorreo); err != nil {
log.Printf("[CRON] Error registrando tarea canales_correo: %v", err)
return
}
// Buzón de soporte por IMAP — cada 2 minutos. No hace nada si no está
// configurado, así que registrarlo siempre no cuesta.
if _, err := cronScheduler.AddFunc("*/2 * * * *", RevisarBuzonSoporte); err != nil {
log.Printf("[CRON] Error registrando tarea soporte_imap: %v", err)
return
}
cronScheduler.Start()
log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min, buzón de soporte cada 2min")
}
// DetenerCron para graceful shutdown
func DetenerCron() {
if cronScheduler != nil {
cronScheduler.Stop()
}
}
// ProcesarVencimientos mantiene compatibilidad para llamadas manuales
func ProcesarVencimientos() {
ProcesarVencimientosProximos()
ProcesarYaVencidos()
}
// ProcesarVencimientosProximos procesa reglas de tipo "vencimiento_proximo"
func ProcesarVencimientosProximos() {
log.Println("[CRON] Procesando vencimientos próximos...")
reglas, err := models.GetReglasByTipoEvento("vencimiento_proximo")
if err != nil {
log.Printf("[CRON] Error obteniendo reglas vencimiento_proximo: %v", err)
return
}
for _, regla := range reglas {
contratos, err := models.GetContratosProximosVencer(regla.DiasAntes)
if err != nil {
log.Printf("[CRON] Error obteniendo contratos para regla %d: %v", regla.ID, err)
continue
}
procesarContratos(&regla, contratos)
}
log.Println("[CRON] Vencimientos próximos finalizado")
}
// diasGraciaVencimiento es cuántos días se sigue insistiendo a un contrato ya
// vencido antes de darlo por perdido y dejar de notificar.
const diasGraciaVencimiento = 30
// ProcesarYaVencidos procesa reglas de tipo "ya_vencido"
func ProcesarYaVencidos() {
log.Println("[CRON] Procesando contratos ya vencidos...")
// Cerrar primero los que llevan demasiado tiempo vencidos sin pagar, para que
// dejen de recibir el correo diario de forma indefinida.
if _, err := models.MarcarContratosVencidos(diasGraciaVencimiento); err != nil {
log.Printf("[CRON] Error marcando contratos vencidos: %v", err)
}
reglas, err := models.GetReglasByTipoEvento("ya_vencido")
if err != nil {
log.Printf("[CRON] Error obteniendo reglas ya_vencido: %v", err)
return
}
if len(reglas) == 0 {
return
}
contratos, err := models.GetContratosYaVencidos()
if err != nil {
log.Printf("[CRON] Error obteniendo contratos vencidos: %v", err)
return
}
for _, regla := range reglas {
procesarContratos(&regla, contratos)
}
log.Println("[CRON] Ya vencidos finalizado")
}
// procesarContratos aplica filtros y envía notificaciones para una regla y lista de contratos
func procesarContratos(regla *models.NotificacionRegla, contratos []models.Contrato) {
if len(contratos) == 0 {
return
}
// Filtrar por AplicaA
var filtrados []models.Contrato
for _, c := range contratos {
switch regla.AplicaA {
case "renovable":
for _, s := range c.Servicios {
if s.Tipo == "renovable" {
filtrados = append(filtrados, c)
break
}
}
case "unico":
for _, s := range c.Servicios {
if s.Tipo == "unico" {
filtrados = append(filtrados, c)
break
}
}
default:
filtrados = append(filtrados, c)
}
}
if len(filtrados) == 0 {
return
}
// Agrupar por cliente
porCliente := make(map[uint][]models.Contrato)
for _, c := range filtrados {
porCliente[c.ClienteID] = append(porCliente[c.ClienteID], c)
}
for clienteID, grupoContratos := range porCliente {
if models.YaEnviadoHoy(clienteID, regla.ID) {
log.Printf("[CRON] Ya enviado hoy a cliente %d para regla %d — saltando", clienteID, regla.ID)
continue
}
cliente := &grupoContratos[0].Cliente
if err := EnviarNotificacionGrupo(regla, cliente, grupoContratos, ""); err != nil {
log.Printf("[CRON] Error enviando a cliente %d: %v", clienteID, err)
} else {
log.Printf("[CRON] Enviado a cliente %d (%s) — %d contrato(s)", clienteID, cliente.Email, len(grupoContratos))
}
}
}
// VerificarPagosBoldPendientes consulta la API de Bold para cada contrato que tiene un
// enlace de pago generado pero aún no confirmado. Se ejecuta cada 15 minutos via cron.
// Esto compensa la ausencia de webhooks de Bold.
func VerificarPagosBoldPendientes() {
contratos, err := models.GetContratosConEnlacePendiente()
if err != nil {
log.Printf("[BOLD-POLL] Error obteniendo contratos pendientes: %v", err)
return
}
if len(contratos) == 0 {
return
}
boldCfg, err := models.GetBoldConfig()
if err != nil {
log.Printf("[BOLD-POLL] Sin configuración Bold activa: %v", err)
return
}
log.Printf("[BOLD-POLL] Verificando %d contrato(s) con enlace pendiente...", len(contratos))
for _, c := range contratos {
paid, paymentID, monto, err := CheckBoldLinkPaid(boldCfg, c.EnlacePagoLinkID)
if err != nil {
log.Printf("[BOLD-POLL] Error consultando link %s (contrato %d): %v", c.EnlacePagoLinkID, c.ID, err)
continue
}
if !paid {
continue
}
ref := fmt.Sprintf("contrato-%d", c.ID)
log.Printf("[BOLD-POLL] Pago detectado para %s (payment_id=%s, monto=%d)", ref, paymentID, monto)
if ok, _ := models.MarcarContratoPagado(c.ID); ok {
go EnviarCorreoConfirmacionPago(c.ID, "bold")
log.Printf("[BOLD-POLL] Contrato %d marcado como pagado", c.ID)
}
// Enriquecer callback log si existe
go models.EnrichBoldCallbackLog(ref, c.Cliente.Email, monto)
// Guardar en webhook log como API_CHECK para trazabilidad
notifID := "api-check-" + paymentID
if paymentID != "" && !models.IsBoldNotificationDuplicate(notifID) {
_ = models.SaveBoldWebhookLog(models.BoldWebhookLog{
NotificationID: notifID,
Tipo: "API_CHECK",
PaymentID: paymentID,
Referencia: ref,
PayerEmail: c.Cliente.Email,
Monto: monto,
Procesado: true,
})
}
}
log.Println("[BOLD-POLL] Verificación completada")
}
// ─── Salud de Servidores ─────────────────────────────────────────────────────
// VerificarSaludServidores corre cada 5 min y genera notificaciones del sistema
// cuando detecta: agente caído, recurso al límite o VPS próximo a vencer.
func VerificarSaludServidores() {
cfg := models.GetServidorAlertaUmbral()
if !cfg.Activo {
return
}
servidores, _, err := models.GetAllServidores(1000, 0, "")
if err != nil {
log.Printf("[CRON-SRV] Error obteniendo servidores: %v", err)
return
}
for _, srv := range servidores {
// 1. Agente caído (sin ping en X minutos)
if srv.AgentToken != "" && srv.AgentLastSeen != nil {
minutos := time.Since(*srv.AgentLastSeen).Minutes()
if minutos > float64(cfg.MinutosSinPing) {
titulo := fmt.Sprintf("🔴 Sin señal: %s", srv.Nombre)
if !models.YaExisteAlertaServidor(titulo, 1) {
crearNotifServidor(titulo,
fmt.Sprintf("El agente no reporta hace %.0f minutos (umbral: %d min)", minutos, cfg.MinutosSinPing),
"servidor_caido")
}
}
}
// 2. Recursos (CPU / RAM / Disco) al límite
if srv.MetricasJson != "" {
var m struct {
CPU struct {
Porcentaje float64 `json:"porcentaje"`
} `json:"cpu"`
RAM struct {
Porcentaje float64 `json:"porcentaje"`
} `json:"ram"`
Discos []struct {
Porcentaje float64 `json:"porcentaje"`
Ruta string `json:"ruta"`
} `json:"discos"`
TopProcs []cronProcInfo `json:"top_procs"`
}
if jsonErr := json.Unmarshal([]byte(srv.MetricasJson), &m); jsonErr == nil {
checkRecurso(srv.Nombre, "CPU", m.CPU.Porcentaje, cfg.UmbralCPU, m.TopProcs, "cpu")
checkRecurso(srv.Nombre, "RAM", m.RAM.Porcentaje, cfg.UmbralRAM, m.TopProcs, "ram")
for _, d := range m.Discos {
label := "Disco"
if d.Ruta != "" && d.Ruta != "/" {
label = "Disco " + d.Ruta
}
checkRecurso(srv.Nombre, label, d.Porcentaje, cfg.UmbralDisco, nil, "")
}
}
}
// 3. Vencimiento próximo
if srv.Vencimiento != "" && cfg.DiasAnteVencimiento > 0 {
vence, parseErr := time.Parse("2006-01-02", srv.Vencimiento)
if parseErr == nil {
dias := int(time.Until(vence).Hours() / 24)
if dias >= 0 && dias <= cfg.DiasAnteVencimiento {
titulo := fmt.Sprintf("📅 %s vence en %s", srv.Nombre, strconv.Itoa(dias)+" días")
if dias == 0 {
titulo = fmt.Sprintf("📅 %s vence HOY", srv.Nombre)
}
if !models.YaExisteAlertaServidor(titulo, 12) {
crearNotifServidor(titulo,
fmt.Sprintf("Fecha de vencimiento: %s. Renueva a tiempo.", srv.Vencimiento),
"servidor_vence_pronto")
}
}
}
}
}
}
func checkRecurso(nombre, recurso string, valor float64, umbral int, procs []cronProcInfo, ordenar string) {
if valor < float64(umbral) {
return
}
titulo := fmt.Sprintf("⚠️ %s alto en %s (%.0f%%)", recurso, nombre, valor)
if !models.YaExisteAlertaServidor(titulo, 1) {
cuerpo := fmt.Sprintf("%s al %.0f%% (umbral: %d%%)", recurso, valor, umbral)
if len(procs) > 0 {
cp := make([]cronProcInfo, len(procs))
copy(cp, procs)
if ordenar == "cpu" {
sort.Slice(cp, func(i, j int) bool { return cp[i].CPU > cp[j].CPU })
} else {
sort.Slice(cp, func(i, j int) bool { return cp[i].RAMMB > cp[j].RAMMB })
}
if len(cp) > 3 {
cp = cp[:3]
}
cuerpo += "\n\nTop procesos:"
for i, p := range cp {
if ordenar == "cpu" {
cuerpo += fmt.Sprintf("\n%d. %s — CPU: %.1f%%, RAM: %.0f MB", i+1, p.Nombre, p.CPU, p.RAMMB)
} else {
cuerpo += fmt.Sprintf("\n%d. %s — RAM: %.0f MB, CPU: %.1f%%", i+1, p.Nombre, p.RAMMB, p.CPU)
}
}
}
crearNotifServidor(titulo, cuerpo, "servidor_recurso_alto")
}
}
func crearNotifServidor(titulo, cuerpo, evento string) {
// Verificar si la notificación del sistema está habilitada para este evento
evtCfg := models.GetNotifConfig(evento, "admin")
// Por defecto, si no hay config, crear notificación de sistema igualmente
if evtCfg == nil || evtCfg.CanalSistema {
_ = models.CreateSistemaNotif(&models.SistemaNotificacion{
TipoUsuario: "admin",
Titulo: titulo,
Cuerpo: cuerpo,
Url: "/app/servidor-dashboard",
Icono: "🖥️",
})
}
// Telegram (si está configurado para este evento)
if evtCfg != nil && evtCfg.CanalTelegram {
msg := fmt.Sprintf("<b>%s</b>\n%s", titulo, cuerpo)
sendTelegramAdmin(msg)
}
}
// ─── Monitor de URLs ──────────────────────────────────────────────────────────
func VerificarUrlMonitors() {
monitors, err := models.GetActiveUrlMonitors()
if err != nil || len(monitors) == 0 {
return
}
now := time.Now()
for _, m := range monitors {
m := m
if m.UltimoCheckAt != nil {
if now.Sub(*m.UltimoCheckAt).Minutes() < float64(m.IntervaloMin) {
continue
}
}
go ejecutarChequeoURL(m)
}
}
func EjecutarChequeoURLPublic(m models.UrlMonitor) { ejecutarChequeoURL(m) }
func ejecutarChequeoURL(m models.UrlMonitor) {
statusCode, latMs, err := doHttpCheck(m.URL, m.TimeoutSeg)
ok := err == nil && statusCode >= 200 && statusCode < 400
errStr := ""
if err != nil {
errStr = err.Error()
}
anteriorOk := m.UltimoOk == nil || *m.UltimoOk
models.UpdateUrlMonitorStatus(m.ID, statusCode, latMs, ok)
models.InsertUrlMonitorLog(m.ID, statusCode, latMs, ok, errStr)
if m.UltimoOk == nil {
return // primer chequeo, no alertar
}
if !ok && anteriorOk {
detalle := errStr
if detalle == "" {
detalle = fmt.Sprintf("HTTP %d", statusCode)
}
msg := fmt.Sprintf("🔴 <b>Sitio caído</b>\n<b>%s</b>\n%s\n\nError: %s",
escapeTelegramHTML(m.Nombre),
escapeTelegramHTML(m.URL),
escapeTelegramHTML(detalle))
sendTelegramAdmin(msg)
log.Printf("[URL-MON] DOWN %s — %s", m.Nombre, detalle)
} else if ok && !anteriorOk {
msg := fmt.Sprintf("✅ <b>Sitio recuperado</b>\n<b>%s</b>\n%s\nLatencia: <b>%dms</b>",
escapeTelegramHTML(m.Nombre),
escapeTelegramHTML(m.URL),
latMs)
sendTelegramAdmin(msg)
log.Printf("[URL-MON] UP %s — %dms", m.Nombre, latMs)
}
}
func doHttpCheck(url string, timeoutSeg int) (int, int64, error) {
if timeoutSeg <= 0 {
timeoutSeg = 10
}
client := &http.Client{
Timeout: time.Duration(timeoutSeg) * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("demasiadas redirecciones")
}
return nil
},
}
start := time.Now()
req, err := http.NewRequest(http.MethodHead, url, nil)
if err != nil {
return 0, 0, err
}
req.Header.Set("User-Agent", "usite-monitor/1.0")
resp, err := client.Do(req)
latMs := time.Since(start).Milliseconds()
if err != nil || (resp != nil && resp.StatusCode == http.StatusMethodNotAllowed) {
if resp != nil {
resp.Body.Close()
}
// Reintentar con GET
start = time.Now()
req2, _ := http.NewRequest(http.MethodGet, url, nil)
req2.Header.Set("User-Agent", "usite-monitor/1.0")
resp2, err2 := client.Do(req2)
latMs = time.Since(start).Milliseconds()
if err2 != nil {
return 0, latMs, err2
}
resp2.Body.Close()
return resp2.StatusCode, latMs, nil
}
resp.Body.Close()
return resp.StatusCode, latMs, nil
}