Files
soft_usite/pkg/models/umind_accion.go
T
Lizandro GuarnizoandClaude Opus 5 b7c52f174a fix(umind): auditoría de las cinco features — cinco defectos y un guard permanente
Revisión de coherencia de todo lo que entró. Cinco cosas mal, ninguna
detectable compilando:

1. LA PUERTA DE TOOLS DECIDÍA AL REVÉS. executeUmindTool encolaba antes de
   clasificar, así que en un canal público CUALQUIER nombre de herramienta
   terminaba como pendiente de aprobación — incluidos los que el modelo
   inventa y los internos, cuyos guards ("solo el dueño") quedaban
   inalcanzables porque vivían más abajo en el flujo. Un visitante del widget
   podía llenarle al dueño la bandeja de solicitudes para ejecutar cosas que
   no existen.

   Ahora se clasifica primero: lectura ejecuta, interna se rechaza si el
   pedido es público, acción se encola, desconocida se rechaza. Verificado
   inyectando la falla: el test falla y vuelve a pasar al arreglarlo.

2. El enlace "Ver el documento" de la bandeja estaba muerto: archivo_id no lo
   escribía nadie. Ahora la acción aprobada engancha el PDF que produjo.

3. Los PDF generados esquivaban la cuota de almacenamiento. El tope del plan
   se evitaba pidiéndole documentos al agente en vez de subiendo archivos.

4. La vista de Consumo no conocía el tipo "documento" — el rubro nuevo salía
   sin nombre ni color. Y el icono que le puse no existía en el set, así que
   habría quedado invisible: el mismo bug de los iconos de hace unas semanas,
   que compila y se ve vacío.

5. Las seis lecturas nuevas se registraron con w() en vez de r(), exigiendo
   SoloAdmin para leer. No era una fuga —era más estricto— pero un usuario
   staff sin admin veía la lista de agentes y 403 en sus avisos.

Y el guard que evita el próximo: TestTodoHandlerUmindValidaAlcance recorre los
65 handlers de uMind y exige que cada uno valide el alcance del tenant, o esté
en una lista de exenciones con el motivo escrito. Los mismos handlers se montan
para staff y para clientes; el middleware de scope deja los tenants permitidos
en el contexto pero no mira el :id de la URL — eso lo hace cada handler, y el
que se olvide compila igual. Al escribirlo el propio test encontró que mi lista
de exenciones sobraba en dos, y verifiqué con un handler con fuga deliberada
que lo detecta.

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

123 lines
5.1 KiB
Go

package models
import (
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindAccionPendiente es una acción que el agente preparó pero no ejecutó,
// esperando el visto bueno de una persona.
//
// No es un motor de workflows: es una tabla y una regla. Las acciones que
// salen hacia afuera (mandar un correo, entregar una cotización) pedidas por
// alguien que no es el dueño no se ejecutan — se encolan.
type UmindAccionPendiente 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"`
Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // correo | documento | herramienta
// Herramienta y ArgumentosJSON son el payload EXACTO que se va a ejecutar.
// Al aprobar se ejecuta esto y no se le vuelve a preguntar al modelo: si
// se regenerara, aprobarías una cosa y saldría otra.
Herramienta string `json:"herramienta" gorm:"column:herramienta;size:60;not null"`
ArgumentosJSON string `json:"-" gorm:"column:argumentos_json;type:text"`
// Resumen es lo que lee la persona. Un botón "Aprobar" sobre un JSON es
// una firma en blanco.
Resumen string `json:"resumen" gorm:"column:resumen;type:text"`
SessionID string `json:"session_id" gorm:"column:session_id;size:120"` // para poder responderle a quien lo pidió
// ArchivoID apunta a la vista previa cuando la acción produce un documento.
ArchivoID *uint `json:"archivo_id" gorm:"column:archivo_id"`
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente';index"`
ResueltaPor string `json:"resuelta_por" gorm:"column:resuelta_por;size:120"`
ResueltaAt *time.Time `json:"resuelta_at" gorm:"column:resuelta_at"`
Motivo string `json:"motivo" gorm:"column:motivo;type:text"`
ExpiraAt time.Time `json:"expira_at" gorm:"column:expira_at;index"`
ResultadoJSON string `json:"resultado_json" gorm:"column:resultado_json;type:text"`
}
func (UmindAccionPendiente) TableName() string { return "umind_acciones_pendientes" }
// UmindAccionVigencia es cuánto vale un pendiente. Una cotización aprobada
// tres semanas tarde llega con precios de otro mes: es peor que ninguna.
const UmindAccionVigencia = 7 * 24 * time.Hour
// UmindAccionMax acota los pendientes por agente. Sin tope, un visitante
// insistente llena la bandeja del dueño y la vuelve inútil.
const UmindAccionMax = 100
func CreateUmindAccion(a *UmindAccionPendiente) error {
var n int64
app.Http.Database.DB.Model(&UmindAccionPendiente{}).
Where("agente_id = ? AND estado = ? AND deleted_at IS NULL", a.AgenteID, "pendiente").Count(&n)
if n >= UmindAccionMax {
return gorm.ErrInvalidData
}
if a.ExpiraAt.IsZero() {
a.ExpiraAt = time.Now().Add(UmindAccionVigencia)
}
return app.Http.Database.DB.Create(a).Error
}
func GetUmindAccionByID(id uint) (*UmindAccionPendiente, error) {
var a UmindAccionPendiente
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
return nil, err
}
return &a, nil
}
// GetUmindAccionesPendientes lista la bandeja del espacio. Es del espacio y no
// de un agente: aprobar es tarea de una persona, y las tareas van en una sola
// bandeja — con tres agentes, tres pantallas significan que nadie revisa.
func GetUmindAccionesPendientes(tenantID uint, incluirResueltas bool) ([]UmindAccionPendiente, error) {
var items []UmindAccionPendiente
q := app.Http.Database.DB.Where("tenant_id = ?", tenantID)
if !incluirResueltas {
q = q.Where("estado = ?", "pendiente")
}
err := q.Order("created_at DESC").Limit(200).Find(&items).Error
return items, err
}
func ContarUmindAccionesPendientes(tenantID uint) int64 {
var n int64
app.Http.Database.DB.Model(&UmindAccionPendiente{}).
Where("tenant_id = ? AND estado = ? AND deleted_at IS NULL", tenantID, "pendiente").Count(&n)
return n
}
// ReclamarAccion toma la acción para ejecutarla, una sola vez. Dos personas
// mirando la misma bandeja pueden apretar "Aprobar" a la vez; sin esto la
// cotización sale dos veces.
func ReclamarAccion(id uint) bool {
res := app.Http.Database.DB.Model(&UmindAccionPendiente{}).
Where("id = ? AND estado = ?", id, "pendiente").
Update("estado", "ejecutando")
return res.Error == nil && res.RowsAffected == 1
}
func CerrarAccion(id uint, estado, quien, motivo, resultado string) error {
ahora := time.Now()
return app.Http.Database.DB.Model(&UmindAccionPendiente{}).Where("id = ?", id).
Updates(map[string]interface{}{
"estado": estado, "resuelta_por": quien, "resuelta_at": &ahora,
"motivo": motivo, "resultado_json": resultado,
}).Error
}
// VencerAccionesViejas cierra las que nadie miró a tiempo.
func VencerAccionesViejas() int64 {
res := app.Http.Database.DB.Model(&UmindAccionPendiente{}).
Where("estado = ? AND expira_at < ?", "pendiente", time.Now()).
Update("estado", "vencida")
return res.RowsAffected
}
// VincularArchivoAAccion engancha el documento producido a la acción.
func VincularArchivoAAccion(accionID, archivoID uint) {
app.Http.Database.DB.Model(&UmindAccionPendiente{}).Where("id = ?", accionID).
Update("archivo_id", archivoID)
}