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
@@ -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
}