diff --git a/main.go b/main.go index 1256fe0..abefdd1 100755 --- a/main.go +++ b/main.go @@ -155,6 +155,8 @@ func main() { &models.UmindHerramienta{}, &models.UmindCanal{}, &models.UmindAviso{}, + &models.UmindArchivo{}, + &models.UmindAccionPendiente{}, &models.UmindConexion{}, &models.UmindEventoLog{}, &models.UmindPlan{}, diff --git a/migrations/migrate.go b/migrations/migrate.go index 220a906..d8705ad 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -127,6 +127,8 @@ func Migrate() { &models.UmindHerramienta{}, &models.UmindCanal{}, &models.UmindAviso{}, + &models.UmindArchivo{}, + &models.UmindAccionPendiente{}, &models.UmindConexion{}, &models.UmindEventoLog{}, &models.UmindPlan{}, diff --git a/pkg/models/umind_accion.go b/pkg/models/umind_accion.go new file mode 100644 index 0000000..43f1a76 --- /dev/null +++ b/pkg/models/umind_accion.go @@ -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 +} diff --git a/pkg/models/umind_archivo.go b/pkg/models/umind_archivo.go new file mode 100644 index 0000000..4c4b325 --- /dev/null +++ b/pkg/models/umind_archivo.go @@ -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() } diff --git a/pkg/models/umind_plan.go b/pkg/models/umind_plan.go index 3d3be8b..d737fe5 100644 --- a/pkg/models/umind_plan.go +++ b/pkg/models/umind_plan.go @@ -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" } diff --git a/pkg/services/cron_service.go b/pkg/services/cron_service.go index 6ff5edb..3d28fa7 100644 --- a/pkg/services/cron_service.go +++ b/pkg/services/cron_service.go @@ -85,6 +85,12 @@ func IniciarCron() { 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. if _, err := cronScheduler.AddFunc("* * * * *", DespacharAvisos); err != nil { log.Printf("[CRON] Error registrando tarea avisos_umind: %v", err) diff --git a/pkg/services/umind_agent_service.go b/pkg/services/umind_agent_service.go index d4bd14f..be41db1 100644 --- a/pkg/services/umind_agent_service.go +++ b/pkg/services/umind_agent_service.go @@ -143,7 +143,17 @@ func umindEmailTools() []agentTool { // no matchea, busca una UmindHerramienta custom del agente y hace el POST al // webhook configurado. Devuelve el resultado ya serializado, en el mismo // 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 { + 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" { consulta, _ := args["consulta"].(string) if strings.TrimSpace(consulta) == "" { diff --git a/pkg/services/umind_aprobacion_service.go b/pkg/services/umind_aprobacion_service.go new file mode 100644 index 0000000..1816942 --- /dev/null +++ b/pkg/services/umind_aprobacion_service.go @@ -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(` + +
+

uMind · %s

+

Tu asistente preparó algo y espera tu visto bueno

+

%s

+ Revisarlo +

Si no lo revisás, vence en 7 días y no se hace nada.

+
`, 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) + } + } +} diff --git a/pkg/services/umind_aprobacion_test.go b/pkg/services/umind_aprobacion_test.go new file mode 100644 index 0000000..04c698b --- /dev/null +++ b/pkg/services/umind_aprobacion_test.go @@ -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") + } +} diff --git a/rest/controllers/umind_accion_controller.go b/rest/controllers/umind_accion_controller.go new file mode 100644 index 0000000..c44437a --- /dev/null +++ b/rest/controllers/umind_accion_controller.go @@ -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" +} diff --git a/rest/controllers/umind_archivo_controller.go b/rest/controllers/umind_archivo_controller.go new file mode 100644 index 0000000..bc94290 --- /dev/null +++ b/rest/controllers/umind_archivo_controller.go @@ -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 +} diff --git a/rest/routes/umind.go b/rest/routes/umind.go index c2eb3a7..079730e 100644 --- a/rest/routes/umind.go +++ b/rest/routes/umind.go @@ -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/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.Delete("/umind/avisos/:id", w(controllers.DeleteUmindAvisoHandler)...) g.Post("/umind/conexiones/imap", w(controllers.CrearConexionImapHandler)...)