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>
This commit is contained in:
Lizandro Guarnizo
2026-08-24 21:32:48 -05:00
co-authored by Claude Opus 5
parent 9da2a057f2
commit b2e2e83fe8
30 changed files with 757 additions and 144 deletions
+141
View File
@@ -0,0 +1,141 @@
package models
import (
"fmt"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindAviso es un recordatorio que el propio dueño le pidió al agente:
// "avisame el 15 de marzo que vence la póliza de Acme, y todos los años".
// Vuelve por donde se pidió — nunca a una dirección que dicte la conversación.
type UmindAviso struct {
gorm.Model
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index;not null"`
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index"` // desnormalizado: listar por espacio sin join
Titulo string `json:"titulo" gorm:"column:titulo;size:200;not null"`
Detalle string `json:"detalle" gorm:"column:detalle;type:text"`
// ProximoAt es cuándo toca. En repeticiones se corre solo hacia adelante.
ProximoAt time.Time `json:"proximo_at" gorm:"column:proximo_at;index;not null"`
Repetir string `json:"repetir" gorm:"column:repetir;size:20"` // "" | diario | semanal | mensual | anual
// Destino es el sessionID de donde salió el pedido ("tg:123", "mail:a@b").
// Vacío = al dueño del espacio por correo.
Destino string `json:"destino" gorm:"column:destino;size:120"`
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente';index"`
UltimoEnvioAt *time.Time `json:"ultimo_envio_at" gorm:"column:ultimo_envio_at"`
Error string `json:"error" gorm:"column:error;type:text"`
}
func (UmindAviso) TableName() string { return "umind_avisos" }
// UmindAvisoMax acota cuántos avisos pendientes puede tener un agente. Sin
// tope, una conversación en bucle programa mil recordatorios.
const UmindAvisoMax = 50
func CreateUmindAviso(a *UmindAviso) error {
var n int64
app.Http.Database.DB.Model(&UmindAviso{}).
Where("agente_id = ? AND estado = ? AND deleted_at IS NULL", a.AgenteID, "pendiente").Count(&n)
if n >= UmindAvisoMax {
return fmt.Errorf("este agente ya tiene el máximo de %d avisos programados", UmindAvisoMax)
}
return app.Http.Database.DB.Create(a).Error
}
func GetUmindAvisosByAgente(agenteID uint) ([]UmindAviso, error) {
var items []UmindAviso
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).
Order("proximo_at ASC").Limit(100).Find(&items).Error
return items, err
}
func GetUmindAvisoByID(id uint) (*UmindAviso, error) {
var a UmindAviso
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
return nil, err
}
return &a, nil
}
// CancelarUmindAviso solo cancela los pendientes: cancelar uno ya enviado no
// significa nada y ocultaría un error de identificación del agente.
func CancelarUmindAviso(id, agenteID uint) error {
return app.Http.Database.DB.Model(&UmindAviso{}).
Where("id = ? AND agente_id = ? AND estado = ?", id, agenteID, "pendiente").
Update("estado", "cancelado").Error
}
func DeleteUmindAviso(id uint) error {
return app.Http.Database.DB.Delete(&UmindAviso{}, id).Error
}
// GetUmindAvisosVencidos devuelve los que ya deberían haber salido.
func GetUmindAvisosVencidos() ([]UmindAviso, error) {
var items []UmindAviso
err := app.Http.Database.DB.
Where("estado = ? AND proximo_at <= ? AND deleted_at IS NULL", "pendiente", time.Now()).
Limit(200).Find(&items).Error
return items, err
}
// ReclamarAviso marca el aviso como "enviando" solo si seguía pendiente, y
// dice si el reclamo fue de este proceso. El cron corre en memoria del
// proceso, sin lock distribuido: con dos instancias del binario levantadas
// cada aviso saldría dos veces. Este UPDATE condicional es el lock — la base
// ya es el árbitro, no hace falta traer otro.
func ReclamarAviso(id uint) bool {
res := app.Http.Database.DB.Model(&UmindAviso{}).
Where("id = ? AND estado = ?", id, "pendiente").
Update("estado", "enviando")
return res.Error == nil && res.RowsAffected == 1
}
// ReprogramarAviso corre la fecha hacia adelante según la repetición y lo
// deja pendiente otra vez. Si no repite, queda enviado.
func ReprogramarAviso(a *UmindAviso) error {
ahora := time.Now()
updates := map[string]interface{}{"ultimo_envio_at": &ahora, "error": ""}
proximo := siguienteFecha(a.ProximoAt, a.Repetir)
if proximo.IsZero() {
updates["estado"] = "enviado"
} else {
// Si el servidor estuvo caído varios ciclos, se salta los perdidos en
// vez de disparar una ráfaga de avisos viejos.
for !proximo.After(ahora) {
proximo = siguienteFecha(proximo, a.Repetir)
}
updates["estado"] = "pendiente"
updates["proximo_at"] = proximo
}
return app.Http.Database.DB.Model(&UmindAviso{}).Where("id = ?", a.ID).Updates(updates).Error
}
func siguienteFecha(desde time.Time, repetir string) time.Time {
switch repetir {
case "diario":
return desde.AddDate(0, 0, 1)
case "semanal":
return desde.AddDate(0, 0, 7)
case "mensual":
return desde.AddDate(0, 1, 0)
case "anual":
return desde.AddDate(1, 0, 0)
}
return time.Time{}
}
// MarcarAvisoFallido lo devuelve a pendiente para el próximo ciclo: una caída
// de SMTP no puede perder un vencimiento de póliza.
func MarcarAvisoFallido(id uint, motivo string) {
app.Http.Database.DB.Model(&UmindAviso{}).Where("id = ?", id).
Updates(map[string]interface{}{"estado": "pendiente", "error": motivo})
}
// SiguienteFechaAviso se exporta solo para poder probar el cálculo de
// repeticiones sin base de datos de por medio.
func SiguienteFechaAviso(desde time.Time, repetir string) time.Time {
return siguienteFecha(desde, repetir)
}