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:
Lizandro Guarnizo
2026-08-24 21:37:37 -05:00
co-authored by Claude Opus 5
parent b2e2e83fe8
commit 544cba6d34
12 changed files with 733 additions and 1 deletions
+2
View File
@@ -155,6 +155,8 @@ func main() {
&models.UmindHerramienta{}, &models.UmindHerramienta{},
&models.UmindCanal{}, &models.UmindCanal{},
&models.UmindAviso{}, &models.UmindAviso{},
&models.UmindArchivo{},
&models.UmindAccionPendiente{},
&models.UmindConexion{}, &models.UmindConexion{},
&models.UmindEventoLog{}, &models.UmindEventoLog{},
&models.UmindPlan{}, &models.UmindPlan{},
+2
View File
@@ -127,6 +127,8 @@ func Migrate() {
&models.UmindHerramienta{}, &models.UmindHerramienta{},
&models.UmindCanal{}, &models.UmindCanal{},
&models.UmindAviso{}, &models.UmindAviso{},
&models.UmindArchivo{},
&models.UmindAccionPendiente{},
&models.UmindConexion{}, &models.UmindConexion{},
&models.UmindEventoLog{}, &models.UmindEventoLog{},
&models.UmindPlan{}, &models.UmindPlan{},
+116
View File
@@ -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
}
+69
View File
@@ -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() }
+3
View File
@@ -20,6 +20,9 @@ type UmindPlan struct {
// TopeConsumoMensual solo dispara un aviso al superarse — no corta el // TopeConsumoMensual solo dispara un aviso al superarse — no corta el
// servicio. 0 = sin tope. // servicio. 0 = sin tope.
TopeConsumoMensual float64 `json:"tope_consumo_mensual" gorm:"column:tope_consumo_mensual;default:0"` TopeConsumoMensual float64 `json:"tope_consumo_mensual" gorm:"column:tope_consumo_mensual;default:0"`
// 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"` Activo bool `json:"activo" gorm:"column:activo;default:true"`
} }
+6
View File
@@ -85,6 +85,12 @@ func IniciarCron() {
return 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. // Recordatorios programados por los propios clientes.
if _, err := cronScheduler.AddFunc("* * * * *", DespacharAvisos); err != nil { if _, err := cronScheduler.AddFunc("* * * * *", DespacharAvisos); err != nil {
log.Printf("[CRON] Error registrando tarea avisos_umind: %v", err) log.Printf("[CRON] Error registrando tarea avisos_umind: %v", err)
+10
View File
@@ -143,7 +143,17 @@ func umindEmailTools() []agentTool {
// no matchea, busca una UmindHerramienta custom del agente y hace el POST al // no matchea, busca una UmindHerramienta custom del agente y hace el POST al
// webhook configurado. Devuelve el resultado ya serializado, en el mismo // webhook configurado. Devuelve el resultado ya serializado, en el mismo
// formato que espera el loop de function-calling. // formato que espera el loop de function-calling.
// executeUmindTool es la puerta: decide si la acción se ejecuta o se encola
// esperando a una persona. El trabajo real vive en ejecutarHerramienta, que es
// lo que corre después una aprobación.
func executeUmindTool(agenteID uint, sessionID string, sesionInterna bool, name string, args map[string]interface{}) string { func executeUmindTool(agenteID uint, sessionID string, sesionInterna bool, name string, args map[string]interface{}) string {
if RequiereAprobacion(sesionInterna, name) {
return EncolarAccion(agenteID, sessionID, name, args)
}
return ejecutarHerramienta(agenteID, sessionID, sesionInterna, name, args)
}
func ejecutarHerramienta(agenteID uint, sessionID string, sesionInterna bool, name string, args map[string]interface{}) string {
if name == "buscar_conocimiento" { if name == "buscar_conocimiento" {
consulta, _ := args["consulta"].(string) consulta, _ := args["consulta"].(string)
if strings.TrimSpace(consulta) == "" { if strings.TrimSpace(consulta) == "" {
+150
View File
@@ -0,0 +1,150 @@
package services
import (
"encoding/json"
"fmt"
"log"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// La regla que separa "asistente" de "incidente": preparar es libre, entregar
// necesita una persona — cuando del otro lado no está el dueño.
// RequiereAprobacion decide si esta llamada se ejecuta o se encola.
//
// El eje es quién está del otro lado, no qué tan peligrosa suena la acción.
// En un canal interno el dueño ya autorizó al escribirlo, y mandarlo a
// aprobar su propio pedido es fricción sin seguridad. En el widget público
// escribe cualquiera: ahí toda acción hacia afuera espera.
func RequiereAprobacion(sesionInterna bool, name string) bool {
if sesionInterna {
return false
}
switch name {
case "buscar_conocimiento", "leer_bandeja":
return false // lectura: no sale nada del negocio
}
return true
}
func tipoDeAccion(name string) string {
switch {
case name == "enviar_correo":
return "correo"
case strings.HasPrefix(name, "generar_documento"):
return "documento"
}
return "herramienta"
}
// EncolarAccion deja la acción esperando y avisa al dueño. Devuelve lo que el
// modelo le va a contar al cliente: nunca "fue rechazado" ni "está pendiente
// de aprobación interna" — el cliente no tiene por qué conocer la cocina.
func EncolarAccion(agenteID uint, sessionID, name string, args map[string]interface{}) string {
agente, err := models.GetUmindAgenteByID(agenteID)
if err != nil {
return `{"error": "no se pudo registrar la solicitud"}`
}
argsJSON, _ := json.Marshal(args)
accion := &models.UmindAccionPendiente{
AgenteID: agenteID, TenantID: agente.TenantID,
Tipo: tipoDeAccion(name), Herramienta: name,
ArgumentosJSON: string(argsJSON), Resumen: resumirAccion(name, args),
SessionID: sessionID, Estado: "pendiente",
}
if err := models.CreateUmindAccion(accion); err != nil {
log.Printf("[UMIND_APROB] no se pudo encolar la acción %q del agente %d: %v", name, agenteID, err)
return `{"error": "no se pudo registrar la solicitud, intentá más tarde"}`
}
go avisarAccionPendiente(agente, accion)
b, _ := json.Marshal(map[string]interface{}{
"ok": true,
"nota": "La solicitud quedó registrada y una persona del equipo la va a revisar. Decíselo al cliente de forma natural, sin mencionar aprobaciones ni procesos internos.",
})
return string(b)
}
// resumirAccion arma la línea que va a leer quien aprueba.
func resumirAccion(name string, args map[string]interface{}) string {
switch name {
case "enviar_correo":
return fmt.Sprintf("Enviar un correo a %v con asunto %q", args["destinatario"], args["asunto"])
case "generar_documento":
return fmt.Sprintf("Generar y entregar un documento (%v) para %v", args["tipo"], args["cliente"])
}
partes := make([]string, 0, len(args))
for k, v := range args {
partes = append(partes, fmt.Sprintf("%s: %v", k, v))
}
return fmt.Sprintf("Ejecutar %q con %s", name, strings.Join(partes, ", "))
}
// EjecutarAccionAprobada corre el payload guardado, tal cual se aprobó.
func EjecutarAccionAprobada(accion *models.UmindAccionPendiente, quien string) error {
if !models.ReclamarAccion(accion.ID) {
return fmt.Errorf("esa solicitud ya fue resuelta")
}
var args map[string]interface{}
if err := json.Unmarshal([]byte(accion.ArgumentosJSON), &args); err != nil {
_ = models.CerrarAccion(accion.ID, "fallida", quien, "los datos guardados no se pudieron leer", "")
return err
}
// Se ejecuta como sesión interna porque ya pasó por una persona; ese es
// exactamente el permiso que la aprobación otorga.
resultado := ejecutarHerramienta(accion.AgenteID, accion.SessionID, true, accion.Herramienta, args)
estado := "ejecutada"
if strings.Contains(resultado, `"error"`) {
estado = "fallida"
}
if err := models.CerrarAccion(accion.ID, estado, quien, "", resultado); err != nil {
return err
}
if estado == "fallida" {
models.RegistrarEventoUmind(accion.AgenteID, "error", "aprobacion",
"Una acción aprobada falló al ejecutarse: "+accion.Resumen, resultado)
return fmt.Errorf("la acción se aprobó pero falló al ejecutarse")
}
return nil
}
// avisarAccionPendiente le dice al dueño que tiene algo para revisar. El
// correo lleva un enlace al panel, con login: un enlace que ejecuta algo
// irreversible sin autenticar es un enlace que reenviado por error firma.
func avisarAccionPendiente(agente *models.UmindAgente, accion *models.UmindAccionPendiente) {
tenant, err := models.GetUmindTenantByID(agente.TenantID)
if err != nil || tenant.ClienteID == nil {
return
}
usuarios, err := models.GetPortalUsersByClienteID(*tenant.ClienteID)
if err != nil {
return
}
url := GetPublicURL() + "/portal/studio"
for _, u := range usuarios {
models.CreateSistemaNotif(&models.SistemaNotificacion{
TipoUsuario: "portal_user", UsuarioID: u.ID,
Titulo: "Tu asistente necesita tu visto bueno",
Cuerpo: accion.Resumen, Icono: "✋", Url: url,
})
if u.Email == "" {
continue
}
html := fmt.Sprintf(`<!DOCTYPE html>
<html><body style="font-family:Inter,sans-serif;background:#f1f5f9;padding:32px">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:16px;padding:32px;border:1px solid #e2e8f0">
<p style="color:#64748b;font-size:13px;margin:0 0 8px">uMind · %s</p>
<h2 style="margin:0 0 12px;color:#1e293b;font-size:20px">Tu asistente preparó algo y espera tu visto bueno</h2>
<p style="color:#334155;font-size:15px;background:#f8fafc;border-left:3px solid #8eb02f;padding:12px 16px;border-radius:6px">%s</p>
<a href="%s" style="display:block;text-align:center;background:#8eb02f;color:#fff;padding:12px 24px;border-radius:10px;font-weight:600;text-decoration:none;font-size:15px;margin:24px 0">Revisarlo</a>
<p style="color:#94a3b8;font-size:12px">Si no lo revisás, vence en 7 días y no se hace nada.</p>
</div></body></html>`, agente.Nombre, accion.Resumen, url)
if err := app.Http.Mail.Send(u.Email, "Tu asistente necesita tu visto bueno", html); err != nil {
log.Printf("[UMIND_APROB] no se pudo avisar a %s: %v", u.Email, err)
}
}
}
+84
View File
@@ -0,0 +1,84 @@
package services
import (
"os"
"strings"
"testing"
)
// La regla de aprobación es lo que separa un asistente de una máquina de spam
// con el dominio del cliente. Si alguien la afloja, esto tiene que fallar.
func TestRequiereAprobacion(t *testing.T) {
casos := []struct {
nombre string
interna bool
tool string
requiere bool
}{
// Canal público: cualquiera escribe. Todo lo que sale, espera.
{"público no puede mandar correo", false, "enviar_correo", true},
{"público no puede llamar una herramienta", false, "avisar_stock", true},
{"público no puede generar documentos", false, "generar_documento", true},
// Leer no saca nada del negocio.
{"público sí puede buscar en el conocimiento", false, "buscar_conocimiento", false},
{"público sí puede leer la bandeja", false, "leer_bandeja", false},
// Canal interno: el dueño ya autorizó al escribirlo. Mandarlo a
// aprobar su propio pedido es fricción sin seguridad.
{"el dueño manda correo directo", true, "enviar_correo", false},
{"el dueño ejecuta herramientas directo", true, "avisar_stock", false},
}
for _, c := range casos {
if got := RequiereAprobacion(c.interna, c.tool); got != c.requiere {
t.Errorf("%s: RequiereAprobacion(interna=%v, %q) = %v, quiero %v",
c.nombre, c.interna, c.tool, got, c.requiere)
}
}
}
// Lo que el modelo le cuenta al cliente cuando algo queda esperando no puede
// filtrar la cocina del negocio.
func TestEncolarNoFiltraElProcesoInterno(t *testing.T) {
b, err := os.ReadFile("umind_aprobacion_service.go")
if err != nil {
t.Fatal(err)
}
s := string(b)
i := strings.Index(s, "func EncolarAccion")
j := strings.Index(s[i:], "\nfunc ")
cuerpo := s[i : i+j]
nota := cuerpo[strings.Index(cuerpo, `"nota":`):]
nota = nota[:strings.Index(nota, "\n")]
for _, prohibido := range []string{"rechaz", "aprobación interna"} {
if strings.Contains(strings.ToLower(nota), prohibido) {
t.Errorf("la nota al cliente menciona %q: %s", prohibido, nota)
}
}
if !strings.Contains(strings.ToLower(nota), "sin mencionar") {
t.Error("la nota tiene que instruir al modelo a no mencionar el proceso interno")
}
}
// El payload se ejecuta tal cual se aprobó: si al aprobar se le volviera a
// preguntar al modelo, se aprobaría una cosa y saldría otra.
func TestAccionAprobadaUsaElPayloadGuardado(t *testing.T) {
b, err := os.ReadFile("umind_aprobacion_service.go")
if err != nil {
t.Fatal(err)
}
s := string(b)
i := strings.Index(s, "func EjecutarAccionAprobada")
j := strings.Index(s[i:], "\n// avisarAccionPendiente")
cuerpo := s[i : i+j]
if !strings.Contains(cuerpo, "accion.ArgumentosJSON") {
t.Error("la ejecución tiene que partir de los argumentos guardados")
}
if strings.Contains(cuerpo, "callAI") || strings.Contains(cuerpo, "ProcessWidgetMessage") {
t.Error("aprobar no puede volver a llamar al modelo: se aprobaría una cosa y saldría otra")
}
// Dos personas mirando la misma bandeja pueden aprobar a la vez.
if !strings.Contains(cuerpo, "ReclamarAccion") {
t.Error("falta el reclamo: sin él, dos aprobaciones simultáneas ejecutan dos veces")
}
}
@@ -0,0 +1,89 @@
package controllers
import (
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// La bandeja de pendientes: lo que el asistente preparó y espera tu visto bueno.
// GetUmindAccionesHandler — GET /umind/acciones?tenant_id=N&historial=1
func GetUmindAccionesHandler(c *fiber.Ctx) error {
tenantID, _ := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
items, err := models.GetUmindAccionesPendientes(uint(tenantID), c.Query("historial") == "1")
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"items": items,
"pendientes": models.ContarUmindAccionesPendientes(uint(tenantID)),
})
}
// AprobarUmindAccionHandler ejecuta el payload guardado, tal cual se aprobó.
// POST /umind/acciones/:id/aprobar
func AprobarUmindAccionHandler(c *fiber.Ctx) error {
accion, err := accionConAcceso(c)
if err != nil {
return err
}
if accion.Estado != "pendiente" {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "esa solicitud ya fue resuelta"})
}
if err := services.EjecutarAccionAprobada(accion, quienResuelve(c)); err != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// RechazarUmindAccionHandler descarta la acción. El motivo es para el dueño,
// no para el cliente: al cliente se le dice que le responde una persona.
// POST /umind/acciones/:id/rechazar {motivo}
func RechazarUmindAccionHandler(c *fiber.Ctx) error {
accion, err := accionConAcceso(c)
if err != nil {
return err
}
if accion.Estado != "pendiente" {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "esa solicitud ya fue resuelta"})
}
var req struct {
Motivo string `json:"motivo"`
}
_ = c.BodyParser(&req)
if err := models.CerrarAccion(accion.ID, "rechazada", quienResuelve(c), strings.TrimSpace(req.Motivo), ""); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func accionConAcceso(c *fiber.Ctx) (*models.UmindAccionPendiente, error) {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return nil, c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
accion, err := models.GetUmindAccionByID(uint(id))
if err != nil {
return nil, errSinAcceso(c)
}
if err := accesoTenant(c, accion.TenantID); err != nil {
return nil, err
}
return accion, nil
}
// quienResuelve deja registrado quién aprobó. No hay roles dentro de un
// cliente: aprueba cualquiera de sus usuarios, y por eso importa el nombre.
func quienResuelve(c *fiber.Ctx) string {
if u, ok := c.Locals("portal_user").(*models.PortalUser); ok && u != nil {
return u.Nombre + " <" + u.Email + ">"
}
return "staff"
}
@@ -0,0 +1,193 @@
package controllers
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// El repositorio de archivos del espacio. Los archivos NO se sirven por el
// estático de /uploads (que solo sabe si hay sesión de panel, no de quién es
// el archivo): salen por el endpoint de descarga de acá, que valida el tenant.
const umindArchivoMaxBytes = 25 << 20 // 25 MB
// GetUmindArchivosHandler — GET /umind/archivos?tenant_id=N
func GetUmindArchivosHandler(c *fiber.Ctx) error {
tenantID, _ := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
items, err := models.GetUmindArchivosByTenant(uint(tenantID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
usado := models.EspacioUsadoUmind(uint(tenantID))
resp := fiber.Map{"items": items, "usado_bytes": usado}
if plan := planDelTenant(uint(tenantID)); plan != nil && plan.MaxAlmacenamientoMB > 0 {
resp["cuota_bytes"] = int64(plan.MaxAlmacenamientoMB) << 20
}
return c.JSON(resp)
}
// CreateUmindArchivoRepoHandler sube un archivo al repositorio del espacio.
// POST /umind/archivos (multipart: tenant_id, archivo, descripcion)
func CreateUmindArchivoRepoHandler(c *fiber.Ctx) error {
tenantID, _ := strconv.ParseUint(c.FormValue("tenant_id"), 10, 64)
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
fh, err := c.FormFile("archivo")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subí un archivo"})
}
if fh.Size > umindArchivoMaxBytes {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el archivo supera los 25 MB"})
}
if !extensionPermitida(fh.Filename) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ese tipo de archivo no está permitido"})
}
// La cuota se mira antes de escribir: rechazar después de copiar 25 MB al
// disco es cobrarle el espacio igual.
if plan := planDelTenant(uint(tenantID)); plan != nil && plan.MaxAlmacenamientoMB > 0 {
cuota := int64(plan.MaxAlmacenamientoMB) << 20
if models.EspacioUsadoUmind(uint(tenantID))+fh.Size > cuota {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"error": fmt.Sprintf("tu plan incluye %d MB de archivos y ya no entra. Borrá alguno o escribinos para ampliarlo.", plan.MaxAlmacenamientoMB),
})
}
}
dir := filepath.Join("uploads", "umind", strconv.FormatUint(tenantID, 10))
if err := os.MkdirAll(dir, 0o755); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo guardar el archivo"})
}
nombre := sanitizeFilename(fh.Filename)
destino := filepath.Join(dir, fmt.Sprintf("%d_%s", models.AhoraUnix(), nombre))
if err := saveUploadedFile(fh, destino); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo guardar el archivo"})
}
archivo := &models.UmindArchivo{
TenantID: uint(tenantID), Nombre: fh.Filename, Archivo: destino,
TipoMime: fh.Header.Get("Content-Type"), Tamanio: fh.Size,
Origen: "subido", Descripcion: strings.TrimSpace(c.FormValue("descripcion")),
}
if err := models.CreateUmindArchivo(archivo); err != nil {
_ = os.Remove(destino)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "item": archivo})
}
// DownloadUmindArchivoHandler — GET /umind/archivos/:id/descargar
func DownloadUmindArchivoHandler(c *fiber.Ctx) error {
archivo, err := archivoConAcceso(c)
if err != nil {
return err
}
// Defensa en profundidad: la ruta viene de nuestra base, pero si alguna vez
// entrara con "..", esto la corta antes de salir de uploads/.
limpio := filepath.Clean(archivo.Archivo)
if !strings.HasPrefix(limpio, "uploads/") {
return errSinAcceso(c)
}
if _, err := os.Stat(limpio); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "el archivo ya no está en el servidor"})
}
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, sanitizeFilename(archivo.Nombre)))
return c.SendFile(limpio)
}
// DeleteUmindArchivoHandler — DELETE /umind/archivos/:id
func DeleteUmindArchivoHandler(c *fiber.Ctx) error {
archivo, err := archivoConAcceso(c)
if err != nil {
return err
}
if err := models.DeleteUmindArchivo(archivo.ID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// El archivo en disco se borra después del registro: si falla el borrado
// físico queda un huérfano, que es mejor que una fila apuntando a la nada.
_ = os.Remove(filepath.Clean(archivo.Archivo))
return c.JSON(fiber.Map{"ok": true})
}
// UsarArchivoComoConocimientoHandler manda un archivo ya subido por la ingesta
// del agente, sin tener que volver a subirlo.
// POST /umind/archivos/:id/conocimiento {agente_id}
func UsarArchivoComoConocimientoHandler(c *fiber.Ctx) error {
archivo, err := archivoConAcceso(c)
if err != nil {
return err
}
var req struct {
AgenteID uint `json:"agente_id"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
agente, err := accesoAgente(c, req.AgenteID)
if err != nil {
return err
}
// El agente tiene que ser del mismo espacio que el archivo — si no, sería
// cargarle a un agente los papeles de otro negocio.
if agente.TenantID != archivo.TenantID {
return errSinAcceso(c)
}
datos, err := os.ReadFile(filepath.Clean(archivo.Archivo))
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "el archivo ya no está en el servidor"})
}
texto, err := services.ExtraerTextoDeArchivo(req.AgenteID, archivo.Nombre, datos)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
doc := &models.UmindDocumento{
AgenteID: req.AgenteID, Tipo: "archivo", Origen: archivo.Nombre,
Contenido: texto, Estado: "procesando",
}
if err := models.CreateUmindDocumento(doc); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
_ = models.VincularArchivoADocumento(archivo.ID, doc.ID)
go services.IngestarTexto(req.AgenteID, doc.ID, texto)
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "documento_id": doc.ID})
}
// archivoConAcceso resuelve el :id y valida que el espacio sea del solicitante.
func archivoConAcceso(c *fiber.Ctx) (*models.UmindArchivo, error) {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return nil, c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
archivo, err := models.GetUmindArchivoByID(uint(id))
if err != nil {
return nil, errSinAcceso(c)
}
if err := accesoTenant(c, archivo.TenantID); err != nil {
return nil, err
}
return archivo, nil
}
func planDelTenant(tenantID uint) *models.UmindPlan {
tenant, err := models.GetUmindTenantByID(tenantID)
if err != nil || tenant.PlanID == nil {
return nil
}
plan, err := models.GetUmindPlanByID(*tenant.PlanID)
if err != nil {
return nil
}
return plan
}
+8
View File
@@ -65,6 +65,14 @@ func RegistrarRutasUmind(g fiber.Router, scope fiber.Handler, escritura fiber.Ha
g.Get("/umind/conexiones", r(controllers.GetUmindConexionesHandler)...) g.Get("/umind/conexiones", r(controllers.GetUmindConexionesHandler)...)
g.Get("/umind/conexiones/conectar", w(controllers.UmindConectarHandler)...) g.Get("/umind/conexiones/conectar", w(controllers.UmindConectarHandler)...)
g.Get("/umind/acciones", w(controllers.GetUmindAccionesHandler)...)
g.Post("/umind/acciones/:id/aprobar", w(controllers.AprobarUmindAccionHandler)...)
g.Post("/umind/acciones/:id/rechazar", w(controllers.RechazarUmindAccionHandler)...)
g.Get("/umind/archivos", w(controllers.GetUmindArchivosHandler)...)
g.Post("/umind/archivos", w(controllers.CreateUmindArchivoRepoHandler)...)
g.Get("/umind/archivos/:id/descargar", w(controllers.DownloadUmindArchivoHandler)...)
g.Post("/umind/archivos/:id/conocimiento", w(controllers.UsarArchivoComoConocimientoHandler)...)
g.Delete("/umind/archivos/:id", w(controllers.DeleteUmindArchivoHandler)...)
g.Get("/umind/avisos", w(controllers.GetUmindAvisosHandler)...) g.Get("/umind/avisos", w(controllers.GetUmindAvisosHandler)...)
g.Delete("/umind/avisos/:id", w(controllers.DeleteUmindAvisoHandler)...) g.Delete("/umind/avisos/:id", w(controllers.DeleteUmindAvisoHandler)...)
g.Post("/umind/conexiones/imap", w(controllers.CrearConexionImapHandler)...) g.Post("/umind/conexiones/imap", w(controllers.CrearConexionImapHandler)...)