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>
151 lines
6.0 KiB
Go
151 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|