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:
co-authored by
Claude Opus 5
parent
9da2a057f2
commit
b2e2e83fe8
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user