Se editaba HTML a ciegas: había que guardar y generar un documento real para
saber si estaba bien. Ahora el modal tiene un panel de vista previa al lado del
editor, que se actualiza mientras se escribe y se abre solo al editar una
plantilla o al importar una con IA.
Se ejecuta la plantilla de verdad contra datos de ejemplo, no se reemplaza
texto: es la única forma de que {{range .Items}} y los campos anidados se vean
como van a salir, y de que un error de sintaxis aparezca mientras se edita en
vez de al generar el PDF. Los datos de ejemplo salen de DatosBaseDocumento,
igual que en producción, para que la vista previa no muestre una cosa y el
documento otra.
El iframe va en sandbox sin scripts ni same-origin: el HTML lo escribe un
admin, pero no tiene por qué correr con los permisos del panel.
De paso, la ayuda de variables ofrecía {{.Cliente.Nit}}, que no existe en el
modelo — el campo es Documento.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
La integración muestra solo "error code: 502" y el motivo no quedaba escrito en
ningún lado. Ahora se loguea.
Y el estado separa los dos casos: 503 si falta configurar la IA de este lado,
502 si el que falló fue el proveedor. Con el mismo código para ambos no se sabe
a quién mirar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
es_agente_bot no se podía marcar desde ninguna pantalla —el formulario nunca
mandaba el campo— y el update lo escribía igual con el valor cero. O sea que
guardar cualquier config desde /app/ai-config apagaba el cerebro del bot de
Telegram y del chat del panel, y no había forma de volver a prenderlo salvo
tocando la base.
- El update solo escribe los campos que vinieron en el body.
- El formulario tiene la casilla y el selector de bot de Telegram.
- Marcar una desmarca la anterior: GetAgenteBotAiConfig hace First(), así que
con dos marcadas ganaba la que estuviera primero en la tabla.
Y el otro comportamiento raro: cuando ningún módulo coincidía, se usaba
"cualquier config activa". Eso podía elegir la de embeddings o la de Whisper,
que no conversan — el error que llegaba era del proveedor y no se parecía en
nada a la causa. Ahora esas quedan excluidas del comodín y, si no queda
ninguna usable, el error dice qué módulo asignar y dónde.
De paso: la etiqueta "IA / vCard" mentía (ese módulo alimenta además soporte,
el chat del panel y las plantillas), el comentario de is_active decía "solo uno
activo a la vez" cuando hace falta uno por módulo, y GetActiveAiConfig no la
usaba nadie.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
El 403 reportaba ip_vista=104.22.14.222, que es un edge de Cloudflare: el
tráfico entra por CF y recién ahí por el proxy del hosting, así que la última
entrada de X-Forwarded-For es el edge y no quien llama.
Se usa CF-Connecting-IP cuando está: la escribe Cloudflare con la IP real y
sobrescribe la que mande el cliente. Sin Cloudflare sigue valiendo la última
entrada del XFF, como estaba.
Queda anotado que todo esto vale mientras el tráfico entre por el proxy: quien
pueda pegarle al origen directo puede falsear los dos encabezados, y eso se
cierra en la red, no en esta función.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Convertir un documento entero en plantilla pide un modelo más capaz que el
resto de las tareas, pero salía por el módulo "IA / vCard", compartido con la
integración de vCard: subir el modelo ahí es subírselo a todo.
Ahora hay un módulo "Plantillas de documento" en /app/ai-config. Si alguna
config activa lo declara, la importación usa esa; si no, sigue saliendo por
"ia" exactamente como hasta ahora. Nadie tiene que configurar nada para que
siga funcionando.
La pantalla de plantillas aclara además que esto no usa un agente de uMind y
adónde ir a cambiar el modelo, que era la duda.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c.IP() detrás del proxy del hosting devuelve la IP interna del contenedor que
reenvía (10.x.x.x). Contra eso, ninguna IP pública cargada en una llave podía
coincidir: toda API key con restricción de IP daba 403, que es exactamente lo
que le pasó a la integración de vCard.
Ahora se lee X-Forwarded-For, y se toma la ÚLTIMA entrada, no la primera: con
un proxy adelante esa es la que escribió el proxy. Si quien llama manda su
propio X-Forwarded-For, el proxy le agrega la IP real al final — quedarse con
la primera sería dejar que cada uno declare su IP y la restricción no valdría
nada. El test fija ese caso.
Mismo arreglo en la autenticación de Pagos Externos, que comparaba igual.
El 403 ahora dice qué IP se vio y cuál está permitida: sin eso, del otro lado
se prueba a ciegas.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
La pantalla lo ofrecía y las rutas de /api/v2/vcard lo exigen, pero la lista de
scopes válidos del backend nunca lo incluyó: crear la llave devolvía "scope
inválido: vcard" y la integración de vCard no tenía forma de autenticarse.
La lista estaba escrita dos veces —una en Go y otra en el HTML— y se
desincronizaron. Ahora la vista la pide al backend, así que no puede volver a
ofrecer algo que el backend rechace, y un test fija que todo scope exigido por
las rutas se pueda asignar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
El sistema de vCard necesitaba usar la IA configurada en /app/ai-config y el
único camino era /api/v1/ia/ollama-generate, que autentica con cookie de
sesión: incómodo para una llamada entre servidores y la razón de los 401.
Ahora hay POST /api/v2/vcard/ia, bajo el mismo scope y la misma API key
(Bearer + IP) que ya usa esa integración para los QR y los VCF. Manda prompt,
recibe texto.
La clave del proveedor no sale del servidor. La alternativa —devolverle la
configuración con la api_key adentro, como hace el Landing Generator— reparte
la credencial de OpenAI/Anthropic a cada aplicación que la quiera usar.
El módulo va fijo en "ia": si lo eligiera quien llama, una llave con scope
vcard podría usar la configuración del agente o la del Landing Generator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Telegram: los avisos se mandaban a todas las configuraciones activas sin mirar
a dónde apuntan. Con el mismo chat cargado en dos filas, cada aviso llegaba dos
veces. Ahora se manda una vez por destino (bot + chat); los chats distintos
siguen recibiendo todos.
Correo al remitente: el camino STARTTLS abría una conexión, hacía StartTLS,
autenticaba… y la descartaba para llamar a smtp.SendMail, que abre otra. La
primera quedaba colgada y el envío real salía por una conexión distinta, que
podía no estar autenticada. Reescrito: una sola conexión, asegurada según la
configuración, y cierre con QUIT.
Y como el acuse se manda en segundo plano, su error moría en el log. Ahora hay
un botón "Enviar correo de prueba" que usa exactamente el mismo camino y
devuelve el error del servidor en pantalla, y el último fallo del acuse real
aparece en el resultado de "Revisar buzón ahora".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
El sistema que consume /v2/vcard/qr-url solo recibía "Error subiendo archivo a
OSS": el motivo quedaba en nuestro log y del otro lado no había nada que hacer
con eso. Ahora la respuesta incluye el error real del proveedor —es una API
entre servidores y el que llama ya conoce la config de OSS que mandó— y el log
dice además qué clave se intentó subir.
La clave del objeto se armaba con el nombre tal como venía en el body. Un
nombre con "/" o con caracteres que OSS no acepta producía justamente ese
error genérico. Ahora se limpia.
El mismo nombre crudo se usaba para el archivo temporal (/tmp/<nombre>.webp),
o sea escritura de archivos en una ruta que elegía quien llamaba. Se fue
entero: los tres endpoints suben desde memoria con UploadFromReader, que
además saca el paso a disco y su limpieza.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Una respuesta a un hilo existente saltea el clasificador; un correo nuevo no.
Por eso el sistema parecía funcionar solo con las respuestas: los correos
nuevos que el modelo consideraba "no soporte" desaparecían sin ticket, sin
acuse al cliente y sin más rastro que un renglón de log que nadie mira.
Dos cambios:
- El filtro solo opina sobre desconocidos. Si el remitente es un cliente o un
usuario del portal, siempre se abre el ticket. Para eso la resolución del
remitente pasa a correr antes del filtro. A un cliente registrado no se le
descarta el correo por lo que diga un modelo.
- Lo descartado se ve. "Revisar buzón ahora" informa cuántos dejó afuera el
filtro, con asunto, remitente y motivo. Guardado en memoria: es diagnóstico
de hace un rato, no algo que valga una tabla.
Además, si la respuesta automática está apagada, el log lo dice al crear el
ticket — era la otra explicación posible de "no me contestó" y no se distinguía.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
En cada ticket, "✨ Borrador" propone una respuesta usando los fragmentos más
parecidos de la base de conocimiento del agente de uMind que se elija en la
configuración de soporte, más el hilo de la conversación.
El borrador cae en el cuadro de respuesta y no se manda: lo revisa una persona
y aprieta enviar. Contestarle a un cliente con lo que dijo un modelo, sin que
nadie lo lea, es la forma más rápida de perderlo.
Sin agente configurado el borrador se arma igual, solo con la conversación —
peor, pero mejor que un error. El prompt le prohíbe inventar precios, plazos o
pasos que no estén en la documentación, y le pide decir qué falta preguntar
cuando no alcanza para resolver.
El cuadro de respuesta pasa a textarea: un borrador de varios renglones no
entraba en un input de una línea.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Un ticket que entraba por correo guardaba la dirección como texto suelto: no se
podía cruzar con nada, el cliente no lo veía en su portal y no había forma de
pedir "todos los tickets de Acme". Ahora al crearlo se resuelve el remitente —
primero contra los usuarios del portal, si no contra el correo del cliente— y el
ticket queda atado a quien escribió. El que no matchea queda como contacto
externo, que también es información.
Solo coincidencia exacta de dirección, nunca por dominio: con gmail.com de por
medio, adivinar ata el ticket al cliente equivocado.
Y la llamada de IA que ya clasificaba el correo ahora devuelve también prioridad
y categoría en el mismo JSON: sin costo ni latencia extra. El campo prioridad
existía desde siempre y nadie lo llenaba. La urgencia la define el problema
descrito, no el tono del mensaje — está dicho en el prompt.
En la lista de tickets se ven los tres datos nuevos: cliente (o "externo"),
categoría y la prioridad que ya se mostraba.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sin ventana, la primera corrida convierte en tickets todo lo que haya sin leer,
que en una casilla de años es una avalancha. Ahora hay un desplegable de
antigüedad máxima —6h, 12h, 24h, 3 días o todo— y por defecto 12 horas.
El corte se hace en dos pasos porque IMAP no da para más: al servidor se le
pide SINCE con un día de margen (SINCE compara solo la fecha, no la hora) y el
corte fino por hora se aplica contra la fecha real de cada mensaje. Afinar el
SINCE sería perder los correos del borde.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Filtro (opcional, apagado por defecto): antes de abrir ticket, la IA lee el
correo y decide si es soporte o ruido —newsletters, notificaciones de
plataformas, facturas de proveedores, spam—. Falla abierto: si la IA no está
configurada o se cae, el ticket se abre igual. Ignorar a un cliente es peor que
tener un ticket de más. Hay un campo de contexto del negocio para los casos
raros de cada uno.
Los correos masivos se descartan por sus propios encabezados (List-Unsubscribe,
Precedence, Auto-Submitted) sin gastar una llamada de IA.
Y el motivo por el que el mismo correo se leía y se contestaba de nuevo: la
deduplicación era solo un SELECT previo, que no sirve si el flag de leído no
llegó a guardarse o si dos instancias leen el buzón a la vez. Ahora hay un
índice único parcial sobre message_id: la base rechaza el segundo, y ese rechazo
se trata como "ya estaba", no como error — sin ticket repetido y sin segunda
auto-respuesta.
Si algún correo no se pudo marcar como leído, "Revisar buzón ahora" lo dice en
vez de dejarlo solo en el log: es la señal de que van a volver a leerse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Usé c.Search, que devuelve números de secuencia, y después le pedí los UIDs al
resultado. go-imap devuelve nil ahí, así que la lista salía siempre vacía: el
cron conectaba bien, buscaba bien, y se iba sin leer nada ni escribir un solo
error en el log. Va con UIDSearch.
Con eso se arreglan las dos cosas: no leía y no creaba tickets.
Además:
- Fetch con Peek: el correo queda marcado como leído solo si se llegó a
procesar. Antes, un fallo a mitad de camino lo perdía para siempre.
- "Probar conexión" ahora informa cuántos mensajes hay en la carpeta y cuántos
sin leer; "Revisar buzón ahora" informa cuántos encontró y cuántos convirtió
en ticket, y avisa si la lectura está desactivada.
- Un correo sin asunto abre ticket como "(sin asunto)" en vez de descartarse en
silencio, y los descartes quedan en el log.
Los tres tests nuevos corren contra un servidor IMAP en memoria: bajar los no
leídos, que marcar como leído los saque de la próxima corrida, y que bajarlos
sin procesarlos no los marque. El primero falla si se vuelve a poner Search.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Los tickets del portal ya avisaban por Telegram; los que entran por correo solo
mandaban un mail al admin, que es justo el canal que nadie mira a tiempo. Ahora
pasan por el mismo despachador de notificaciones, así que se prenden y apagan
desde /app/notif-config como cualquier otro evento — nuevo ticket y respuesta
del cliente por separado.
El cuerpo del aviso es un resumen cuando el correo pasa los 700 caracteres
(hilos reenviados, texto pegado); abajo de eso va tal cual, porque resumir tres
renglones es gastar una llamada de IA para decir lo mismo. Si la IA falla, se
manda recortado. El ticket guarda siempre el texto completo.
DispatchTicketNuevo ahora acepta portalUser nil: un correo no tiene usuario de
portal detrás y no por eso hay que dejar de avisar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Escribí una segunda implementación de "llamar al proveedor" en vez de usar la
que ya existe (callAI, la del bot de Telegram). Esa copia no hablaba Anthropic
—se lo mandaba todo al endpoint de OpenAI— y, si la config tenía la URL base
vacía (lo normal: la vista dice que es opcional), armaba una URL relativa que
ni siquiera es una petición válida. Cualquiera de las dos cosas terminaba en
el 502.
Ahora usa callAI, que despacha por proveedor y completa la URL base. El error
del proveedor queda en el log del servidor y el mensaje llega entero a la vista;
si la respuesta no trae mensaje, la vista muestra el status para distinguir un
fallo de la app de uno del proxy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Faltaba la tercera pata: audio pasaba por Whisper, imagen por OCR, y un PDF o
un Word adjunto se ignoraba en silencio. Ahora hay un interruptor por canal
—"Leer archivos adjuntos"— y los documentos que llegan por Telegram o WhatsApp
se convierten a texto antes de pasar al agente.
PDF se lee con stdlib cuando el documento es digital (facturas, cotizaciones,
lo exportado por cualquier programa) y cae al OCR si es un escaneo. Word .docx
es un zip con XML adentro; texto plano, CSV y JSON van directo.
El agente recibe el contenido con contexto ("el cliente adjuntó X, y escribió
Y"), no el chorizo pelado: sin eso el modelo contesta como si el cliente
hubiera tipeado una factura.
De paso la importación de plantillas gana PDF, que usa el mismo extractor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hasta ahora crear una plantilla era escribir HTML con variables de Go a mano.
Ahora se sube el documento que ya existe (.docx, .html, .txt o una foto del
papel), se lee —docx con stdlib, imágenes por OCR— y la IA lo devuelve armado
como plantilla con las variables del generador puestas donde iban los datos.
No se guarda solo: el HTML cae en el editor para revisarlo, y si la IA devolvió
sintaxis de plantilla inválida se avisa antes de guardar.
PDF queda afuera por ahora; el mensaje de error dice qué hacer en su lugar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Los clientes escriben a soporte@ desde su correo de siempre. Hasta ahora eso
solo llegaba si un proveedor (SendGrid/Mailgun) nos hacía POST; si nadie lo
configuraba, los correos quedaban sin leer en el buzón.
Ahora el cron entra al buzón cada 2 minutos, baja los no leídos, abre ticket
(o los engancha al hilo si son respuesta) y los marca como leídos. La lógica
de ingesta se movió a services para que webhook e IMAP se comporten igual.
La contraseña del buzón se guarda cifrada (AES-GCM con APP_KEY) y no vuelve
al navegador.
De paso, dos bugs que impedían guardar la configuración: el formulario mandaba
id=0 (gorm.Model serializa "ID"), así que cada guardado creaba una fila nueva
en vez de editar la que se usa; y Updates con struct ignoraba los booleanos en
false, así que desactivar algo no tenía efecto.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
En el portal, un cliente con un solo espacio y un solo agente tenía que
elegir dos veces antes de llegar a lo suyo. Ahora entra directo.
Y la primera pestaña deja de ser configuración: lo que le importa al dueño
es qué le están preguntando sus clientes, y poder probarlo. Para el staff
el orden queda igual.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lo introduje yo al agregar el enlace: quedó con "hidden sm:inline-flex", así
que en un teléfono el cliente no tenía forma de entrar a Studio. Ahora el
enlace se ve siempre y lo que se oculta en pantallas chicas es solo el
texto, porque no entra al lado de la campana y el nombre de usuario — el
logo alcanza para reconocerlo y el acceso no puede faltar.
Del resto del portal en móvil: medido a 492px, ningún elemento desborda y
scrollWidth coincide con el ancho de la ventana. Las grillas del dashboard
ya tenían breakpoints y las pestañas del proyecto ya scrollean solas, así
que ahí no había nada que arreglar.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Primera tanda de la auditoría responsive, sobre el panel y el portal.
De 59 vistas con tabla, 55 ya envolvían la tabla en un contenedor con
scroll propio; las 4 que faltaban (url_monitor, servidor_dashboard,
partner_recursos, notif_config) hacían scrollear la página entera de lado
en un teléfono. Ahora tienen el mismo contenedor que el resto.
Y una red por debajo en los dos layouts: en pantallas chicas el desborde
se contiene en vez de romper el layout, y pre/code scrollean en su caja.
Es para lo que se agregue después y se olvide el contenedor — el mismo
criterio que se usó con los modales, porque revisar 59 vistas a mano cada
vez que se suma una garantiza que alguna se escape.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El SPA no tenía ningún tratamiento responsive: un sidebar fijo de 256px
siempre visible. En un teléfono de 375px eso deja 119px para el contenido.
Ahora en móvil el sidebar sale de flujo y entra deslizándose desde la
izquierda, con botón en el header y fondo oscuro para cerrarlo. Se cierra
solo al elegir un tenant: dejarlo abierto tapa justo la pantalla a la que
se acaba de entrar. En escritorio (md+) no cambia nada — sigue fijo y sin
botón.
El estado vive en lib/ui.js porque lo tocan dos componentes que no son
padre/hijo: el botón del header y el propio sidebar.
También se achica el padding del contenido en pantallas chicas y las tabs
del agente pasan a scrollear en su propia caja en vez de desbordar.
Medido a 492px de ancho: scrollWidth igual a innerWidth y ningún elemento
sobresaliendo. Escritorio verificado sin regresión.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dos de las cuatro mejoras de la auditoría del agente.
1. Las herramientas aceptan cliente_nombre además de cliente_id.
De 27 parámetros que eran IDs, 10 eran de cliente. Para cada acción el
modelo tenía que encadenar: listar_clientes → leer la respuesta →
encontrar la fila → extraer el número → recién ahí llamar a la
herramienta. Cuatro pasos, y basta que falle uno para que no se guarde
nada — es la explicación más probable de la factura que nunca se creó,
porque ese cliente podía no venir en la primera página del listado.
Ahora el modelo pasa el nombre y el servidor lo resuelve, con búsqueda
completa y no paginada. Si hay varios candidatos devuelve la lista para
que el modelo pregunte cuál, en vez de elegir uno: cargarle una factura
a la empresa equivocada es peor que preguntar. Una coincidencia exacta
gana sobre las parciales, así "Metropolitana" no queda ambiguo solo
porque existe "Metropolitana Norte".
2. Contadores de uso y fallo por herramienta, expuestos como
estado_herramientas.
Cada problema se venía diagnosticando de a un caso, reconstruyendo qué
pasó después de que el usuario avisara. Ahora se puede preguntar
directamente qué viene fallando y ver si es una herramienta puntual o el
modelo eligiendo mal. En memoria a propósito: alcanza para responder eso
y no agrega una tabla.
Quedan las otras dos: adelgazar el prompt de 5,4 KB moviendo los manuales
por dominio a las descripciones de las herramientas, y comparar modelos con
un mismo caso. La primera conviene hacerla con el sistema estable, porque
toca cómo decide el modelo en todos los flujos a la vez.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmado que la factura nunca se creó: la lista de /app/facturas no
filtra nada que pudiera esconderla. La herramienta falló y el modelo
informó "FACTURA GUARDADA EXITOSAMENTE" igual.
El commit anterior le prohíbe eso por prompt, pero un prompt es una
sugerencia. Esto es la garantía: el código junta los errores que
devolvieron las herramientas durante la conversación y los agrega a la
respuesta. Si algo no se completó, se ve, diga lo que diga el modelo.
No reemplaza al prompt — el modelo sigue debiendo explicar el fallo con
sus palabras. Es la red por debajo, para que un "guardado exitosamente"
sobre algo que no se guardó no pueda pasar desapercibido otra vez.
Sigue sin saberse por qué falló aquella vez, porque hasta hoy el resultado
de las herramientas no se registraba. Con el log y este aviso, el próximo
intento lo va a decir en el momento.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Auditoría de qué expone /api/v2 contra qué puede hacer el bot. El bot no
llama a la API: usa su propio registro de herramientas, así que la API
puede crecer y el bot queda atrás sin que nada lo señale.
De 46 herramientas contra ~300 endpoints de v2, el hueco más caro estaba
en el flujo que se estuvo usando hoy:
- Se podían crear cuentas por cobrar y por pagar, pero no marcarlas
pagadas — y marcar pagado es justamente lo que genera la transacción en
contabilidad. O sea que el bot podía registrar deuda pero nunca cerrarla.
- No había forma de corregir una factura mal cargada, que es exactamente lo
que hacía falta después del bug del monto en cero.
Se agregan actualizar_factura, marcar_cuenta_cobro_pagada y
marcar_cuenta_pagar_pagada. actualizar_factura solo pisa los campos que
vinieron, así que corregir el monto no borra el número ni el concepto.
Quedan sin herramientas áreas enteras que v2 sí cubre (hostinger,
cloudflare, websms, portal-usuarios, uMind, api-keys, saas, plantillas).
No se agregan a ciegas: cada herramienta ocupa lugar en el prompt y
compite por la atención del modelo, así que conviene sumarlas cuando haya
un uso real detrás.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Por qué no aparecía la factura: la única herramienta de venta del bot era
adjuntar_factura, y exige un documento pendiente en el chat. Al dictarle
los datos por texto el modelo no tenía con qué guardar — y como el prompt
tampoco se lo prohibía, informó "guardada exitosamente" sobre algo que
nunca se creó.
/api/v2/facturas ya permitía crearlas, pero el bot no llama a la API REST:
usa su propio registro de herramientas, así que solo puede hacer lo que ese
registro expone. El hueco estaba ahí, no en la API.
crear_factura toma cliente_id y monto (obligatorios), número, concepto y
fechas. Devuelve el monto realmente guardado y el id, que es lo que el
prompt le exige informar en vez de repetir lo que dijo el usuario.
Las descripciones de las dos herramientas se remiten entre sí para que el
modelo no elija la equivocada: con documento adjunto va adjuntar_factura,
con datos dictados va crear_factura. El test verifica esa distinción,
porque elegir mal reproduce exactamente el fallo original.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El bot informó "FACTURA GUARDADA EXITOSAMENTE" con $290.000 y el registro
no aparecía. Revisando el camino salieron tres cosas, y las tres explican
por qué el mensaje del bot no se podía tomar como prueba de nada.
1. Los montos se leían con getInt, que solo acepta números JSON. Si el
modelo manda el monto como texto ("290000", "$290.000") devuelve el
valor por defecto: 0. Y con decimales los truncaba. La factura quedaba
creada en cero mientras el bot informaba el monto correcto, porque el
bot repite lo que dijo el usuario, no lo que se guardó. Ahora hay un
getFloat que acepta número o texto y limpia $ , y espacios.
2. Se registraba la llamada a la herramienta pero no su resultado, así que
no había forma de saber si guardó o devolvió error. Ahora se registra
también el resultado: es la única fuente confiable de qué pasó, porque
el mensaje del bot lo escribe el modelo.
3. El prompt no prohibía informar éxito sin haberlo verificado. Ahora se
le exige no decir que guardó algo si la herramienta devolvió error, y
confirmar con los valores que devolvió la herramienta y no con los que
dijo el usuario.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El módulo Facturas tiene ruta, vista y seed, pero no aparecía en el menú.
No faltaba: MenuMiddleware arma el menú solo con los submódulos del rol del
usuario, y esa parte corre ANTES del bypass de IsAdmin.
O sea que un administrador entra a cualquier ruta escribiéndola, pero solo
ve en el menú lo que su rol tenga asignado. Un módulo completo puede
parecer inexistente por eso, y no hay nada en pantalla que lo explique.
Ahora, si el usuario es administrador, el menú se arma con todos los
submódulos: coincide con el acceso que ya tiene. Para el resto no cambia
nada — siguen viendo solo lo suyo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
En /app/contabilidad/cuentas-cobro la petición respondía bien pero la tabla
quedaba en blanco, sin nada que dijera qué había pasado.
Probando la vista real con axios simulado quedó claro que el problema no
era el renderizado: con datos pinta bien, y que falle /clientes/select
tampoco la rompe. Lo que faltaba era distinguir el caso vacío.
Dos arreglos, uno en cada punta:
- Las 8 consultas de contabilidad declaraban `var items []T`, que sin filas
se serializa como null y no como []. La vista hacía x-for sobre null y no
pintaba nada; peor, con null se colaba además una fila basura.
- Ninguna de las 5 tablas tenía estado vacío. Ahora dicen "No hay ...
registradas", así que cero filas se lee como cero filas.
El x-for además queda blindado con (items || []) para no depender de que
todos los endpoints devuelvan siempre una lista.
Verificado con la vista real en tres escenarios: con datos pinta la fila,
vacío y null muestran el mensaje y ninguna fila de más.
Si la pantalla sigue sin traer registros después de esto, ya no es la
vista: es que la consulta no encuentra filas, y el mensaje lo va a decir.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Con ~40 submódulos repartidos en una docena de módulos, encontrar una
pantalla era ir abriendo y cerrando secciones.
El buscador filtra por título de submódulo y también por nombre de módulo
(quien escribe "integraciones" espera ver todo lo que cuelga de ahí). La
comparación normaliza acentos y mayúsculas: nadie escribe "Documentación"
con tilde cuando busca. Los módulos sin coincidencias se ocultan, Enter
entra al primer resultado y Esc limpia.
Dos decisiones que salieron de verlo fallar:
- La lógica va en un <script> y no dentro de x-data="{...}". El atributo se
corta con la primera comilla doble, así que un comentario con comillas
rompía la barra entera de forma silenciosa.
- El filtrado es una pasada imperativa sobre el DOM en vez de una condición
por elemento. Las condiciones dependían de una lista armada al iniciar y
no se re-evaluaban al escribir: el menú se filtraba a medias. Recorrer
unas decenas de nodos por tecleo no se nota y no deja lugar a esas
sutilezas.
Tampoco se interpola el título dentro de las expresiones: va en data-, que
Go sí escapa bien. Verificado con títulos que traen comillas y ampersand,
que con la versión anterior rompían el binding.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Primera tanda del hueco reportado en el commit anterior: el permiso tapaba
la página HTML pero no los datos. Cualquier usuario con sesión podía pedir
GET /app/loadusers o DELETE /app/doc/paginas/:id escribiendo la URL, sin
tener el submódulo asignado.
No alcanzaba con poner MenuMiddleware en esas rutas: compara la ruta pedida
contra los submódulos del rol, y el endpoint de datos vive en otra ruta que
la pantalla (/app/loadusers alimenta /app/users). Compararla consigo misma
nunca coincidiría y dejaría afuera hasta a quien sí tiene el permiso.
RequiereModulo("/app/users") declara en la ruta a qué pantalla pertenece el
endpoint, y valida ese submódulo. El administrador sigue entrando a todo,
igual que en MenuMiddleware.
Esta tanda cubre lo que más duele: identidad y permisos (usuarios, roles,
módulos, submódulos), donde una fuga es escalada de privilegios, y
Documentación, que es el módulo que disparó la auditoría.
Quedan las otras tandas (contabilidad, facturas, clientes, servidores...).
Se hace por partes a propósito: cerrar 400 rutas de una puede dejar gente
afuera de pantallas que hoy usa.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El administrador no aparecía en /app/users.
La lista de usuarios del sistema filtraba con una lista blanca
(tipo_usuario 'sistema', '' o NULL) y la pantalla de usuarios 'gas' es la
complementaria. Un usuario con cualquier otro valor —o con uno viejo de
antes de que existiera el campo— no salía en NINGUNA de las dos: quedaba
invisible en todo el panel aunque pudiera entrar y fuera administrador.
Ahora se excluye 'gas' en vez de exigir 'sistema', así todo usuario cae en
exactamente una de las dos listas.
Además la búsqueda usaba LIKE, que en Postgres distingue mayúsculas:
buscar "lizandro" no encontraba a "Lizandro", así que el atajo obvio para
comprobarlo tampoco funcionaba. Pasa a ILIKE y también busca por email,
que es lo que la mayoría escribe.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Auditoría del sistema de permisos a partir del 404 en /app/doc/paginas.
1. La comparación de permisos usaba solo el último segmento de la URL
(lo que va después del último '/'). Rompía en las dos direcciones: con
permiso sobre /app/doc/categorias se entraba a cualquier otra ruta
terminada en "categorias", y a la vez el permiso parecía no aplicarse
donde sí correspondía porque el segmento coincidía por casualidad.
Ahora se compara la ruta completa, cortando en el separador para que
/app/doc/paginas no habilite /app/doc/paginas-privadas.
2. El submódulo de Documentación nunca se sembró, aunque las rutas y las
vistas existen desde mayo. Había que crear el ítem del menú a mano, y
una URL mal tipeada ahí se ve exactamente igual que un permiso mal
asignado: un 404. Ahora se siembra con la URL correcta.
3. El submódulo "Statuspage" apuntaba a /app/statuspage, que no existe en
ninguna parte — la página real es la pública /status. Se corrige el
seed y también el registro ya creado en la base.
4. Chequeo en el arranque que recorre los submódulos de la base y avisa
cuáles apuntan a una URL sin ruta. Cubre los creados a mano, que es
justo donde el compilador y los tests no llegan.
5. Test que cruza las URLs sembradas contra las rutas registradas: fue el
que encontró lo de statuspage.
Queda pendiente y es más grande: de 454 rutas protegidas solo 54 pasan por
MenuMiddleware. El permiso gatea la página HTML pero no los endpoints de
datos — cualquier usuario autenticado puede llamar a
GET /app/doc/loadpaginas o DELETE /app/doc/paginas/:id sin tener el
submódulo asignado. Se reporta antes de tocarlo porque cerrarlo de golpe
puede dejar gente afuera.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Al navegar de /tenants/2 a /tenants/3, vue-router reusa la misma instancia
del componente porque es la misma ruta con otro parámetro. onMounted no
vuelve a dispararse, así que la vista seguía mostrando los agentes del
tenant anterior.
Las tres vistas tenían el mismo problema: TenantAgentes, AgenteDetail y
Uso. Ahora reaccionan al parámetro (watch con immediate) en vez de al
montaje.
En AgenteDetail se limpia el estado antes de pedir los datos nuevos: si no,
durante la carga se ven los documentos, canales y conversaciones del agente
anterior bajo el nombre del nuevo, que es peor que una pantalla vacía.
Verificado navegando de verdad entre dos tenants con agentes distintos: la
lista pasa de "Ventas T2" a "Soporte T3 / Cobros T3".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Error en producción al crear un tenant:
null value in column "site_key" of relation "umind_tenants"
violates not-null constraint (SQLSTATE 23502)
Es un bug que introduje yo. Al pasar uMind a multi-agente saqué SiteKey de
UmindTenant y renombré TenantID→AgenteID en seis tablas. GORM agrega
columnas pero nunca las borra ni les cambia las restricciones, así que las
viejas quedaron en la base CON su NOT NULL original — y el INSERT nuevo ya
no las incluye.
Al mirarlo, el alcance era mayor que el error reportado: no es solo
site_key. Las seis tablas renombradas tienen su tenant_id huérfano también
NOT NULL, así que fallaba insertar documentos, chunks, mensajes, tools,
canales y conexiones. En la práctica uMind quedaba inutilizable después de
desplegar el refactor: ni crear un tenant, ni ingestar conocimiento, ni
guardar un mensaje del chat.
La migración corre en cada arranque, antes de MigrarUmindAgentes, y
consulta information_schema para no intentar el ALTER a ciegas en una
instalación nueva donde la columna no existe.
No se hace DROP COLUMN a propósito: los datos viejos quedan por si hay que
reconciliar algo. Solo se libera la restricción.
El test compara los nombres de tabla contra el TableName() real de cada
modelo. Un typo ahí haría que la migración no encuentre la columna y siga
de largo: el bug seguiría vivo y el arranque se vería sano.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
La integración VCard vivía solo en /api/v1, que autentica por cookie de
sesión: obliga a manejar cookie jar, no permite allowlist por IP y no se
puede revocar sin tocar la contraseña del usuario (ver
docs/api-v1-contrato.md, punto 7).
Ahora los mismos controladores están también bajo /api/v2/vcard/* con
Bearer + IP + scope, que es lo que administra la pantalla /app/api-keys.
El scope "vcard" acota la llave a estos 12 endpoints: sin él, esa
integración tendría acceso a los otros ~300 de v2.
/api/v1 se mantiene intacto — esto es un camino nuevo, no un reemplazo
forzado, así que lo que ya está instalado sigue andando mientras migran.
Se agregan dos chequeos porque el compilador no ve ninguno de los dos
errores: que las 12 rutas queden registradas con su método y path (un
typo se descubriría recién con un 404 del lado del integrador), y que
todas figuren en el spec de /api/v2, que se mantiene a mano y se
desincroniza en silencio.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Responde las 10 preguntas que mandaron sobre admin.u-site.app leyendo el
código del servidor en vez de inferirlas desde el cliente, con cita de
archivo y línea en cada una.
Lo que sale de ahí:
- expires_in sí es timestamp absoluto (su lectura era correcta), pero el
token se emite con 90 años de vigencia porque Login no pasa vencimiento.
- La API v1 NO acepta Bearer: autentica solo por la cookie
Verify-Rest-Token, así que el placeholder "..." sin completar deja los 12
endpoints en 401. Es la causa más probable de que la integración nunca
haya autenticado por sí misma.
- Hay DOS textos de error 401 ("Token not found" y "Invalid Attempt"), y su
reintento solo cubre el segundo.
- El envoltorio doble-codificado de dLocal es real, nadie lo adivinó mal.
- generate-qr devuelve imagen binaria, no JSON.
Incluye los pendientes de nuestro lado (vigencia del token, Secure=false en
la cookie, auth por cookie en una API máquina-a-máquina) y la recomendación
de usar /api/v1/pagos-externos, que ya usa Bearer + IP, para integraciones
nuevas.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cuatro cosas que ya estaban a medio construir y no se usaban como canal.
1. El badge del widget ahora es un enlace con UTM. Cada cliente ya te
estaba dando exposición en su sitio y no se capitalizaba. El host sale
de la URL del propio script, así funciona igual en cualquier entorno.
2. Reporte del mes por agente en Excel: conversaciones atendidas, con qué
las abrió el visitante, y el consumo desglosado. Es lo que el cliente
necesita para justificar el gasto puertas adentro — un panel al que hay
que entrar no sirve para eso, un archivo que se reenvía sí. Reusa el
generador de xlsx del cronograma.
3. Aviso automático de agentes sin base de conocimiento (cron diario). Un
agente sin fuentes responde de memoria e inventa datos, el cliente
concluye que el producto no sirve y se va. Es el punto donde más gente
se cae y se detecta solo. Se avisa UNA vez por agente, usando el log de
auditoría como registro de envío para no necesitar tabla nueva ni
convertir el recordatorio en spam.
4. Tarjeta de uMind en el dashboard del portal: atajo si el cliente ya lo
tiene, oferta si no. Es el punto de contacto más barato que hay — ya
entró, ya confía, y el cobro se suma a la factura que ya recibe. El
mensaje lidera con notas de voz y fotos, que es lo más difícil de
copiar de lo que tenemos.
La consulta de conversaciones agrupa en SQL en vez de traerse el historial
entero para agrupar en Go.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Auditoría de /portal/dashboard. Cuatro problemas, todos en el mismo bucle:
1. El avance se veía un render atrasado. ActualizarProgresoProyecto corría
DESPUÉS de cargar los proyectos: escribía el valor nuevo en la base pero
los structs ya cargados seguían con el viejo, que es lo que se renderiza.
El usuario veía el cálculo de la visita anterior.
2. N+1 con escrituras en un GET: dos COUNT y un UPDATE por proyecto. Con 10
proyectos, 30 consultas y 10 escrituras por cada carga del dashboard —
incluyendo las de cualquier bot que pase. Ahora es UNA consulta agrupada
y ninguna escritura; los caminos que tocan una fase ya mantienen la
columna al día, así que recalcular en el GET no aportaba nada.
3. Orden aleatorio de los grupos: se recorría un map de Go, así que un
partner veía sus clientes en distinto orden en cada recarga.
4. isPartner se decidía con u.Rol mientras el alcance se decidía con
Role.EsPortalPartner. Desincronizados, un usuario veía proyectos de
varios clientes sin agrupar, o la vista agrupada vacía. Ahora hay una
sola definición (PortalUser.EsPartner) con test de los dos sentidos.
Además, el error de carga se descartaba con `_` y el usuario terminaba
viendo "no tenés proyectos", indistinguible de una caída de la base.
Sin hallazgos de seguridad: el chequeo de acceso por cliente está en todas
las rutas, la sesión revalida Activo en cada request, y las plantillas no
usan x-html ni template.HTML, así que Go escapa todo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El cliente ya veía el roadmap en pantalla pero no tenía cómo pasárselo a
alguien que no entra al portal.
El .xlsx se genera con archive/zip + encoding/xml de la stdlib en vez de
sumar una dependencia de Excel para una sola pantalla. Un CSV renombrado
no servía: en Excel en español el separador y los acentos se rompen, y
esto es un archivo que el cliente le entrega a un tercero.
Detalles que hacen que Excel lo acepte y se lea bien: inline strings (sin
tabla sharedStrings, así ningún índice puede apuntar a la cadena
equivocada), encabezado en negrita y congelado, anchos de columna según
el contenido, y nombre de hoja saneado (Excel rechaza el archivo si pasa
de 31 caracteres o trae : \ / ? * [ ]).
El endpoint repite el chequeo de acceso de PortalProyecto: sin eso,
cualquiera con sesión de portal se bajaba el cronograma de otro cliente
adivinando el slug.
Verificado abriendo el archivo generado con openpyxl: 6 partes, CRC OK,
XML bien formado en todas, acentos y CJK intactos, negrita y panel
congelado donde corresponde.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
La lista de agentes era un directorio: nombre y activo/inactivo. No decía
si el agente estaba realmente listo para atender.
- Tarjetas en grid con el color de marca del agente (el mismo que ve el
visitante en el widget) y sus iniciales.
- Tres datos por agente: conversaciones de los últimos 7 días, fuentes de
conocimiento y canales activos. Salen de tres consultas agrupadas, no de
tres por agente: el N+1 en un listado es el que después no se saca.
- Badge de estado accionable. "sin conocimiento" cuando el agente está
activo pero no tiene ninguna fuente — ese agente responde de memoria e
inventa datos, que es exactamente el problema de la URL falsa, y hasta
ahora no se veía hasta que un cliente recibía la respuesta inventada.
- Esqueleto de carga: la pantalla hace 3 llamadas y quedaba en blanco el
tiempo suficiente como para parecer rota.
Arregla además el fondo en modo oscuro: index.html traía
<body class="bg-gray-50">, y esa utilidad le ganaba a la regla de body de
la capa base, así que el área principal quedaba clara con el tema oscuro
puesto (medido: --fondo resolvía a 9 13 22 pero el body pintaba 249 250 251).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Se podían crear 2 agentes con un plan de 1 sin que nada lo explicara. El
límite del backend sí funciona, pero solo aplica si el tenant tiene un
plan asignado — y hasta 115f167 los campos Cliente y Plan no aparecían en
el formulario del tenant, así que en la práctica quedaban todos sin plan
y sin límite.
El problema de fondo era que ese estado no se veía por ningún lado: un
tenant sin plan se comportaba igual que un límite roto.
- La lista de agentes devuelve también el plan y cuántos hay usados.
- La pantalla muestra "Básico · 1 de 1", "Pro · ilimitado" o "sin plan ·
sin límite", y en este último caso explica que hay que asignarle uno.
- El botón + Nuevo agente se deshabilita al llegar al tope, en vez de
dejar que el límite aparezca recién como un error al guardar.
El backend sigue siendo la autoridad: esto es feedback, no la validación.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
En pantallas bajas (o con formularios largos como el de portal-usuarios)
el panel del modal se salía de la ventana y no había forma de llegar a
Guardar ni a Cancelar.
La regla va en el layout y no en cada vista: son ~214 modales repartidos
en las plantillas y arreglarlos uno por uno garantizaba olvidarse varios.
El selector toma solo los overlays centrados y deja afuera los 4 paneles
laterales, que ya manejan su propio scroll.
El alto se mide en % y no en vh/dvh: el overlay es inset-0, o sea que ya
es exactamente la caja visible. Medido en Edge headless a 600/900/700/420
de alto: el panel queda siempre en ventana-2rem, el tope nunca se recorta,
y Guardar se alcanza scrolleando. Con ventana alta el panel conserva su
alto natural y no scrollea.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
/app/api/clientes/select devuelve el array pelado y /app/umind-planes/list
lo envuelve en {items}. El código leía solo .registros/.items, así que con
el array quedaba vacío — y el bloque estaba detrás de v-if="clientes.length",
o sea que Cliente y Plan simplemente no se veían y no había forma de
asignarlos.
- lista() acepta las dos formas (verificado contra ambas y contra
vacío/null/undefined).
- Se cargan con allSettled: que falle uno ya no vacía el otro.
- El bloque se muestra siempre para el staff, y cada select dice a dónde ir
si su lista está vacía. Antes un fallo de red se veía exactamente igual
que "no hay nada configurado", que es lo que enmascaró este bug.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
La ruta /portal/studio existía pero no había ningún enlace: el cliente
tenía que escribir la URL a mano, o sea que en la práctica no podía
entrar.
El enlace solo aparece si ese cliente tiene al menos un espacio de uMind
asignado — se consulta el mismo endpoint con alcance que usa el SPA, así
ningún cliente sin uMind ve un enlace que lo llevaría a una pantalla
vacía.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El servicio devolvía la transcripción correcta y la reportábamos como
error: "respuesta inesperada del servicio de transcripción: <la
transcripción completa>". La causa es que mandábamos response_format=json,
que es la convención de la API de OpenAI — whisper-asr-webservice usa el
query param `output`, así que ignoró el campo y respondió en su formato
por defecto (txt), y el parser exigía JSON.
- textoDeRespuestaWhisper acepta las dos formas (JSON {"text":...} o texto
pelado) en vez de adivinar qué variante corre del otro lado. Con test:
8 casos, incluidos JSON sin campo text y un texto que empieza con "{".
- Whisper y OCR: la caja de resultado ahora muestra el conteo de
caracteres, scrollea si es largo, y tiene Copiar y Descargar .txt (Blob
+ <a download>, el texto ya está en el navegador).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
El agente estaba dando https://escuelametropolitana.com/login.php, una
ruta que no existe: el prompt decía "no inventes datos" pero no decía
nada de URLs, teléfonos ni correos, que es justo lo que un modelo
confabula con más confianza porque "sabe" que un login suele estar en
/login.php. Un enlace inventado deja a la persona sin poder completar el
trámite, peor que no responder.
- Regla explícita: URLs, teléfonos, WhatsApp y correos solo si aparecen
LITERALMENTE en los resultados de buscar_conocimiento, copiados carácter
por carácter, sin completar rutas ni cambiar el dominio.
- Widget: los enlaces se vuelven clickeables. El contenido sale de páginas
crawleadas, así que NO se inserta como HTML: se parte por URL y cada
trozo va con createTextNode o un <a> con rel=noopener noreferrer
nofollow. Verificado que un <img onerror> o un <script> en la respuesta
quedan escapados como texto inerte.
- WhatsApp: preview_url=true, así el primer enlace se ve como tarjeta
clickeable en vez de texto pelado.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>