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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b2e2e83fe8
commit
544cba6d34
@@ -0,0 +1,116 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UmindArchivo es el repositorio del espacio: contratos firmados, pólizas,
|
||||
// cotizaciones viejas. Vive a nivel tenant y no de agente, porque los archivos
|
||||
// son del negocio — si mañana crea un segundo agente, sus papeles no se mudan.
|
||||
type UmindArchivo struct {
|
||||
gorm.Model
|
||||
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:255;not null"`
|
||||
Archivo string `json:"-" gorm:"column:archivo;size:500;not null"` // ruta en disco, nunca al cliente
|
||||
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime;size:120"`
|
||||
Tamanio int64 `json:"tamanio" gorm:"column:tamanio"`
|
||||
Origen string `json:"origen" gorm:"column:origen;size:20;default:'subido'"` // subido | generado
|
||||
DocumentoID *uint `json:"documento_id" gorm:"column:documento_id"` // si se usó como conocimiento
|
||||
SubidoPor string `json:"subido_por" gorm:"column:subido_por;size:120"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
}
|
||||
|
||||
func (UmindArchivo) TableName() string { return "umind_archivos" }
|
||||
|
||||
func CreateUmindArchivo(a *UmindArchivo) error {
|
||||
return app.Http.Database.DB.Create(a).Error
|
||||
}
|
||||
|
||||
func GetUmindArchivosByTenant(tenantID uint) ([]UmindArchivo, error) {
|
||||
var items []UmindArchivo
|
||||
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).
|
||||
Order("created_at DESC").Limit(500).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetUmindArchivoByID(id uint) (*UmindArchivo, error) {
|
||||
var a UmindArchivo
|
||||
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func DeleteUmindArchivo(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&UmindArchivo{}, id).Error
|
||||
}
|
||||
|
||||
// EspacioUsadoUmind suma los bytes guardados por un espacio, para el tope
|
||||
// del plan. Se cuenta lo que hay, no lo que se subió alguna vez.
|
||||
func EspacioUsadoUmind(tenantID uint) int64 {
|
||||
var total int64
|
||||
app.Http.Database.DB.Model(&UmindArchivo{}).
|
||||
Where("tenant_id = ? AND deleted_at IS NULL", tenantID).
|
||||
Select("COALESCE(SUM(tamanio), 0)").Scan(&total)
|
||||
return total
|
||||
}
|
||||
|
||||
// VincularArchivoADocumento deja constancia de que este archivo ya se cargó
|
||||
// como conocimiento, para no ingerir dos veces el mismo PDF.
|
||||
func VincularArchivoADocumento(archivoID, documentoID uint) error {
|
||||
return app.Http.Database.DB.Model(&UmindArchivo{}).Where("id = ?", archivoID).
|
||||
Update("documento_id", documentoID).Error
|
||||
}
|
||||
|
||||
// AhoraUnix da el prefijo de tiempo para los nombres de archivo en disco.
|
||||
func AhoraUnix() int64 { return time.Now().Unix() }
|
||||
@@ -20,7 +20,10 @@ type UmindPlan struct {
|
||||
// TopeConsumoMensual solo dispara un aviso al superarse — no corta el
|
||||
// servicio. 0 = sin tope.
|
||||
TopeConsumoMensual float64 `json:"tope_consumo_mensual" gorm:"column:tope_consumo_mensual;default:0"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
// MaxAlmacenamientoMB acota el repositorio de archivos del espacio.
|
||||
// 0 = sin límite.
|
||||
MaxAlmacenamientoMB int `json:"max_almacenamiento_mb" gorm:"column:max_almacenamiento_mb;default:200"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (UmindPlan) TableName() string { return "umind_planes" }
|
||||
|
||||
Reference in New Issue
Block a user