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
+%s
+ Revisarlo +Si no lo revisás, vence en 7 días y no se hace nada.
+