Compare commits

..
136 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Opus 5 22158a10a7 feat(studio): rediseño de la pantalla del agente — estado arriba, tres zonas, probar al lado
La pantalla estaba organizada por tabla de base de datos: siete pestañas que
eran nuestras siete entidades —documentos, herramientas, canales, conexiones,
eventos— y que se desbordaban a lo ancho. Un dueño de negocio nunca piensa "voy
a Conexiones": piensa "¿por qué contestó mal?" o "quiero que sepa mis precios
nuevos".

Tres cambios.

Un estado arriba que responde "¿está bien mi asistente?" sin un clic:
"Atendiendo en WhatsApp · Sabe de 2 cosas · 1 conversación", y en ámbar cuando
algo falta — "No está atendiendo en ningún lado", "Todavía no sabe nada de tu
negocio". Antes eso había que deducirlo entrando pestaña por pestaña. Cada
señal es un botón que lleva a donde se arregla, y si el destino está en
Avanzado, lo despliega.

Tres zonas en lugar de siete pestañas: Conversaciones, Lo que sabe, Dónde
atiende. Herramientas, Cuentas conectadas y Problemas pasan a "Avanzado",
plegado. No se sacan —hacen falta— pero dejan de competir todos los días con lo
que se mira todos los días.

Y el conocimiento con la prueba lado a lado. El bucle real es leer lo que sabe
→ probar → corregir → probar de nuevo; en dos pestañas separadas eso eran seis
clics por corrección. Desde 1024px van en dos columnas, con el chat fijo al
hacer scroll; abajo de eso se apilan, que es el orden en que igual se trabaja.

El vocabulario deja de ser el nuestro: "Base de conocimiento" es "Lo que sabe",
"Auditoría" es "Problemas", "Conexiones" es "Cuentas conectadas". Y la site_key
sale del encabezado: se usa una vez, al instalar el widget, y vive donde se
instala.

No se tocó ni el color ni la tipografía ni Umi. Esto es estructura, no pintura:
cambiando las dos cosas a la vez no se sabría cuál mejoró qué.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:11:53 -05:00
Lizandro GuarnizoandClaude Opus 5 c78fcce89b refactor(studio): partir la pantalla del agente en un componente por pestaña
AgenteDetail tenía 1026 líneas con las siete pestañas adentro: el estado de
todas mezclado en un solo <script setup>, y cualquier cambio en una obligaba a
leer las otras seis para saber qué se rompía. Reorganizarla en ese estado sería
trabajar a ciegas.

Queda en 514 líneas —el encabezado, las pestañas y la carga de datos— más un
componente por zona: conocimiento sigue adentro por ahora, y salen auditoría,
conexiones, chat, conversaciones, herramientas y canales.

El padre sigue siendo dueño de los datos y cada pestaña emite "recargar" en vez
de tener su propia copia: con copias por pestaña, ir y volver entre dos mostraba
estados distintos de lo mismo.

Sin un solo cambio visible, y verificado como tal: se capturaron las siete
pestañas antes de empezar y se compararon píxel a píxel después de cada
extracción. Las siete dan idénticas — la única diferencia que reporta el
comparador está por debajo del umbral y cae exactamente sobre el punto que late
en una fuente "procesando", o sea la animación fotografiada en otro instante.

Este commit no cambia nada para el usuario. Es la base para poder reorganizar
la pantalla sin romperla.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:05:09 -05:00
Lizandro GuarnizoandClaude Opus 5 ee9bb2cfb7 feat(studio): un solo set de iconos en vez de emojis mezclados con símbolos
En la misma pantalla convivían dos lenguajes: emojis a color (🌙 📊 🧠 ✍️ 📄
🌐 🔁) y símbolos tipográficos monocromos (✎ ✕ ← ↻ ⚠ ⬇ ✓). Eso se lee como
descuido, y encima los emojis los dibuja el sistema operativo — el mismo panel
se ve distinto en Mac, en Windows y en Android, sin que podamos hacer nada.

Ahora hay un set propio de iconos SVG de trazo: mismo grosor de línea, misma
caja, y heredan currentColor, así que toman el color del texto que los rodea y
funcionan igual en tema claro y oscuro. Al lado uno de otro tienen el mismo
peso visual, que es lo que hacía falta.

El emoji suelto de la pantalla de inicio pasa a ser Umi, que ya es el personaje
del producto.

Un detalle de implementación que costó dos intentos: los iconos NO pueden
declararse como cadenas de HTML para v-html. Dentro de un <svg>, v-html parsea
como HTML y los <path> quedan sin el namespace de SVG: aparecen en el DOM pero
no dibujan nada, así que la primera versión se veía perfecta en el código y en
blanco en pantalla. Van declarados como pares [etiqueta, atributos] para que
Vue los cree con el namespace correcto.

Y AgenteDetail usaba cinco iconos sin importar el componente. Verificado ahora
sobre todos los archivos: cada componente usado está importado donde se usa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:45:43 -05:00
Lizandro GuarnizoandClaude Opus 5 002b75e71b feat(studio): que se note que hay una IA atrás, sin fondo animado permanente
Un fondo animado corriendo todo el día en una herramienta de trabajo se ve bien
diez segundos y molesta las ocho horas siguientes, además de tener un canvas
comiéndose la batería. Así que el movimiento va donde hay algo pasando de
verdad, y el fondo queda quieto.

Al entrar, una vez por sesión: el campo de puntos conectándose durante poco más
de un segundo, y se destruye. Es el mismo lenguaje visual de la página pública
—puntos que se enlazan, como el espacio vectorial donde vive el conocimiento—
así que refuerza la marca en vez de inventar algo para adentro.

En los momentos de espera, que es donde el producto se veía muerto: el chat de
prueba decía "Pensando..." en gris y ahora muestra a Umi con los tres puntos,
el mismo gesto que ve el visitante en el widget. Y una fuente en "procesando"
tenía la etiqueta quieta, sin decir si seguía avanzando o se había colgado:
ahora late.

El fondo es una retícula de plano técnico, tenue y estática, que se desvanece
hacia abajo para no competir con las tablas y los formularios.

Dos cosas que aparecieron al verificar:

El apagado de la animación de entrada estaba dentro del requestAnimationFrame,
que el navegador no corre en pestañas de fondo — abrir el Studio en una pestaña
que no se está mirando dejaba el canvas puesto para siempre, tapando la
interfaz con una capa invisible. Ahora el temporizador se programa aparte.

Y una fuente sin procesar mostraba "leído sin procesar", que no quiere decir
nada: si no hay fecha de lectura, no se escribe la frase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:44:40 -05:00
Lizandro GuarnizoandClaude Opus 5 b97814d62d fix(soporte): un envío de correo sin base de datos tumbaba el proceso entero
soporteSendMail leía la configuración SMTP sin verificar que hubiera base. Con
la conexión en nil eso es un desreferenciado de puntero, y como el envío corre
en su propia goroutine no hay recover que lo contenga: se lleva puesto el
proceso.

En producción no se veía porque siempre hay base. Lo que sí bloqueaba era la
suite de pkg/services, que no se podía correr entera desde hacía tiempo —
cualquier test que tocara el auto-respuesta de soporte hacía explotar toda la
corrida y tapaba el resultado del resto.

Ahora devuelve un error en vez de reventar, y los 7 paquetes con tests del
proyecto pasan en verde.

(utils/xopen tiene 2 tests que fallan: es una dependencia de terceros incluida
en el repo desde el commit inicial y no la toca nada de esto.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:43:24 -05:00
Lizandro GuarnizoandClaude Opus 5 a8cc5c6289 fix(umind): la respuesta genérica no quedaba en la conversación, y eso perpetuaba el error
Cuando el agente caía en "dame un poco más de detalle", esa respuesta no se
guardaba. En Conversaciones quedaban las preguntas del visitante una tras otra
sin ninguna respuesta, así que el dueño no tenía forma de enterarse de que su
agente estaba fallando — justo la pantalla donde debería verlo.

Lo grave es lo otro: ese historial se le reenvía al modelo en cada turno. Un
modelo que ve cuatro preguntas seguidas y ninguna respuesta lee una
conversación rota, se confunde y vuelve a fallar. El primer error se
perpetuaba solo, y por eso una vez que empezaba ya no salía más.

Ahora se guarda siempre lo que el visitante recibió, sea la respuesta buena o
la genérica.

Las sesiones que ya quedaron con el historial roto se normalizan solas: la
ventana es de 6 mensajes, así que después de un par de intercambios buenos los
huecos salen de contexto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:41:13 -05:00
Lizandro GuarnizoandClaude Opus 5 cdfbff605e fix(umind): el agente se quedaba sin rondas buscando y nunca llegaba a responder
Los eventos de producción mostraban "se agotaron las rondas" tres veces
seguidas en un agente que respondía bien en el chat de prueba. La diferencia no
era el widget: era la conversación. Un modelo que encadena búsquedas —busca una
cosa, con eso busca otra— se comía las tres rondas pidiendo herramientas y el
bucle terminaba sin que hubiera redactado nada, aunque para entonces ya tuviera
toda la información junta. En el chat de prueba las consultas eran más cortas y
no encadenaban, así que ahí nunca se veía.

Ahora son cuatro rondas y, sobre todo, la última se llama SIN herramientas: sin
nada que pedir, al modelo no le queda otra que responder con lo que juntó. Que
es exactamente lo que se quiere en el último turno.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:32:13 -05:00
Lizandro GuarnizoandClaude Opus 5 97e66b82e3 fix(umind): el aviso de tope se repetía en cada despliegue y el cliente no se enteraba
Qué pasa cuando un cliente supera el tope de consumo de su plan: el servicio
sigue funcionando y el excedente se factura en el próximo ciclo. Es la decisión
de diseño y no cambia — cortarle el asistente a un negocio en medio de una
conversación con un cliente suyo es peor que la factura.

Lo que sí estaba mal es cómo se avisaba.

El control de "a este ya le avisé" vivía en memoria, con un ponytail: que
asumía reinicios raros. Con despliegue automático en cada push, ese flag se
borra varias veces por día: el mismo cliente pasado de tope recibía el aviso de
nuevo en el siguiente mensaje, y otra vez, y otra. Ahora se marca en la base,
con un UPDATE condicionado que además lo hace atómico — dos mensajes que crucen
el tope a la vez, o dos réplicas del proceso, avisan una sola vez.

Y el aviso iba sólo al Telegram del staff. El dueño del negocio, que es el que
va a recibir la factura con el excedente, no se enteraba por ningún lado: tenía
que entrar al panel a mirar. Ahora le llega un correo que dice cuánto lleva
consumido, cuánto incluye su plan y —lo más importante— que su asistente sigue
funcionando con normalidad.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:20:54 -05:00
Lizandro GuarnizoandClaude Opus 5 9dba93e7f4 feat(widget): mostrar que el asistente está escribiendo
Entre que el visitante manda y el agente contesta pasan varios segundos —más si
tiene que buscar en la base de conocimiento— y el widget se quedaba mudo. Sin
señal de vida, la reacción normal es pensar que se colgó y volver a mandar.

Ahora aparecen los tres puntos que cualquiera reconoce de WhatsApp, y el campo
se bloquea mientras responde: mandar tres mensajes seguidos desordena el hilo y
multiplica el consumo sin que nadie lo pida.

La limpieza va en un then final y no en el de éxito: si quedara ahí, un error
de red dejaría los puntitos animándose para siempre y el campo bloqueado, que
es peor que no haber puesto nada. Probado con la respuesta normal y con un
fetch que falla.

El indicador se inserta siempre al final, así que un mensaje que llegue mientras
tanto no lo deja en el medio del hilo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:18:09 -05:00
Lizandro GuarnizoandClaude Opus 5 44686d409a fix(umind): el agente descartaba la respuesta que ya había escrito
Cuando se agotan las tres rondas de herramientas sin una respuesta final, el
visitante recibe "dame un poco más de detalle". Pero un modelo puede mandar
texto Y pedir una herramienta en el mismo turno, y ese texto se tiraba: si
después se agotaban las rondas, la persona recibía el mensaje genérico aunque
el agente ya le hubiera contestado bien.

Ahora se guarda lo último que escribió y se usa antes de caer en el genérico.

Y el evento que quedaba en Auditoría decía "se agotaron las rondas" sin nada
más, que no alcanza para saber dónde mirar. Ahora registra qué herramientas
pidió en orden, y cada vez que una devuelve error queda su propio evento con la
respuesta cruda. Una herramienta que falla y un modelo que la reintenta es la
forma más común de agotar las rondas, y hasta ahora había que deducirlo.

Esto no arregla la causa de un agente puntual que responde el genérico a todo
—esa sale de los eventos que ahora sí se registran— pero deja de esconder
respuestas buenas y dice dónde buscar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:19:17 -05:00
Lizandro GuarnizoandClaude Opus 5 b125ceef95 fix(umind): la config de IA de un cliente podía atender tareas del sistema
Auditando el aislamiento apareció el agujero al revés del que se buscaba: no
un cliente leyendo datos de otro, sino la cuenta de IA de un cliente pagando
trabajo nuestro.

GetAiConfigForService recorre las configs activas y devuelve la primera sin
módulo asignado. Las configs de cliente no llevan módulo — ninguna lo lleva, es
parte del diseño — así que caían justo en ese fallback. Con un cliente que
hubiera conectado su cuenta, su clave terminaba clasificando correos de
soporte, importando plantillas o atendiendo la vCard. Ninguno de los dos se
enteraba: la respuesta llegaba igual y la factura le llegaba a él.

Todos los resolvedores globales filtran ahora tenant_id IS NULL. Un test lo
verifica sobre el código de cada uno, porque son consultas a base y acá no hay
una.

El de embeddings además no podía ser de cliente por otra razón: los vectores de
todos los agentes tienen que salir del mismo modelo o la similitud coseno entre
ellos no significa nada. Un cliente con su propio modelo de embeddings rompía
su propia búsqueda sin un solo error visible.

Del alcance entre clientes, que era lo que se auditaba: los 39 handlers de
uMind validan, y el CRUD de espacios y planes ni siquiera se monta en las rutas
del portal. Lo que faltaba era prueba: UmindScopeDe distingue "staff" de
"cliente sin espacios" por nil contra slice vacío, y esa diferencia no tenía
un solo test. Ahora la cubre uno que además falla si se invierte el fail-closed
de una ruta sin scope — probado inyectando las dos fugas.

Y dos cosas que quedaban colgando:

En /app/ai-config toda config de cliente se mostraba como "Global", que es
justo lo que no es. Ahora dice de qué espacio es, por nombre.

El consumo de una cuenta propia se registraba con el costo del plan. Se sigue
midiendo —el cliente quiere ver cuánto usa su asistente— pero con costo cero y
marcado como cuenta propia: cobrarlo también sería cobrar dos veces lo mismo.
En la pantalla de consumo aparece "va por tu cuenta de IA" en vez de un "$0"
que parecería un error. El OCR y la transcripción siguen costando: son
servicios nuestros, los use quien los use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:15:28 -05:00
Lizandro GuarnizoandClaude Opus 5 926bbde9fb feat(umind): que el cliente conecte su propia cuenta de IA
El modelo soportaba una config de IA por espacio desde hace varias fases, pero
no había forma de cargarla: ni el cliente desde el portal, ni el staff por él.
Quedaba como una columna que solo se podía llenar tocando la base a mano.

Ahora hay una pantalla — "Tu IA", desde la lista de agentes — donde se conecta
una cuenta de OpenAI, Anthropic, Gemini, Groq, DeepSeek, Qwen u Ollama. Con el
botón "Probar", que manda una consulta real: una clave vencida se descubre ahí
y no cuando un cliente escribe y no le contestan.

El aislamiento es la parte delicada, y va en tres capas:

Las configs globales son del staff. Un cliente puede usarlas —le aparecen en el
selector— pero no editarlas ni borrarlas; el acceso corta antes de mirar
permisos, así que ni siquiera se le confirma que existen. Sin eso, cualquiera
podría cambiarle el proveedor de IA a todos los demás o dejarlos sin servicio.

El tenant sale del alcance ya validado, nunca del body. Si viniera del cuerpo
de la petición, bastaría con cambiar el número para colgarle una config a otro
cliente, y con ella una clave que no es suya.

El tenant no se puede mover en una edición, por lo mismo al revés: sería
regalarle la propia.

La clave se guarda cifrada y no vuelve nunca al navegador — ni al dueño. Solo
sus últimos cuatro caracteres, que alcanzan para reconocer cuál cargó.

Borrar está bloqueado si hay agentes usándola: si no, quedarían apuntando a
algo inexistente y cayendo al proveedor global sin que nadie se entere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:08:43 -05:00
Lizandro GuarnizoandClaude Opus 5 195590f5e4 fix(umind): faltaba el botón para editar las notas del conocimiento
Las plantillas de rubro dejan las notas con valores entre corchetes y la propia
pantalla dice "abrilas y reemplazalas por tus datos" — pero no había con qué.
El endpoint para editarlas existía desde que se agregaron las fuentes de texto;
lo que nunca se agregó fue el botón, así que el flujo que el producto promete
no se podía completar. Se elegía la plantilla de restaurante y quedaban cuatro
notas diciendo "[12:00]" sin forma de arreglarlas salvo borrarlas y escribirlas
de nuevo.

Ahora cada nota tiene "Editar": abre el contenido, se corrige y al guardar se
rehacen los fragmentos, así que el agente pasa a contestar con lo nuevo en el
momento.

También se puede editar el texto que se le extrajo a un archivo: si el OCR de
un PDF salió torcido, corregirlo a mano es tan válido como escribir la nota, y
es el mismo código. Las URLs no — su contenido se rehace crawleando y cualquier
corrección se perdería en la próxima actualización.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:03:58 -05:00
Lizandro GuarnizoandClaude Opus 5 3ceb61c035 feat(umind): Umi, la mascota, y un solo vocabulario en toda la interfaz
La mascota. Umi sale del logo, no es un dibujo aparte: su cuerpo es la "u" del
monograma y el punto del logo pasa a ser su antena, así que símbolo y personaje
son la misma forma vista dos veces. Por eso puede estar al lado del logo sin
competirle.

Tiene estados, y no son decoración: cada pantalla vacía dice algo distinto y la
cara lo dice antes que el texto. Dormida cuando todavía no pasó nada, buscando
cuando falta cargarle información, contenta cuando no hay errores, alerta
cuando algo se rompió. Los ojos toman el color de la superficie de atrás en vez
de ser blancos, así se apoya sobre cualquier tarjeta y en modo oscuro no quedan
dos puntos flotando.

Las pantallas vacías. Había una buena y ocho que decían "Sin canales
configurados." y nada más — el usuario quedaba resolviendo solo qué significa
eso y qué hacer. Ahora las ocho explican qué va ahí y por qué importa: "No está
atendiendo en ningún lado", "Todavía no sabe nada de tu negocio".

El vocabulario. La misma cosa tenía dos nombres según la pantalla: tenant y
espacio, tool y herramienta. Un producto que se llama distinto a sí mismo en
cada lugar se siente como varios productos pegados. Queda: espacio, agente,
herramienta, fuente. Y "al AI" pasa a "a la IA", que además es el género que ya
usaba el resto del panel.

Y la barra de scroll de las pestañas, que se veía gris y gruesa sobre el
contenido: en escritorio las siete entran holgadas y no hacía falta, en móvil
siguen deslizándose pero sin la barra encima.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:19:01 -05:00
Lizandro GuarnizoandClaude Opus 5 5086087e1b feat(umind): duplicar un agente para usarlo como plantilla del siguiente
El catálogo de rubros da un arranque genérico, pero el mejor punto de partida
para el segundo restaurante es el primero — el que ya tiene el conocimiento
real, el tono ajustado y las herramientas andando. Ahora se puede copiar: se
lleva conocimiento, tono, bienvenida, color, config de IA y herramientas.

Tres cosas no se copian, y es a propósito:

Los canales. Llevan las credenciales de una cuenta concreta de WhatsApp o
Telegram; copiarlas haría que dos agentes contesten por el mismo número.

Las claves de las herramientas. Son secretos de un tercero, atados a una
cuenta. La copia llega sin ellas y, si la herramienta las necesitaba, llega
desactivada — para que la falta se note al configurarla y no cuando un cliente
recibe un error.

La site_key. Es la identidad pública del widget y tiene índice único:
compartirla sería servir dos agentes distintos bajo el mismo nombre.

El conocimiento se rehace desde cero en vez de copiar los vectores. Es más
lento, pero los chunks viejos pueden venir de otro modelo de embeddings, y
mezclar vectores de modelos distintos rompe la comparación por similitud —
la búsqueda devolvería cualquier cosa.

Permisos: se verifica el acceso al agente de origen y también al tenant
destino. Copiar es crear, y crear en un tenant ajeno tampoco corresponde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:28:15 -05:00
Lizandro GuarnizoandClaude Opus 5 af71b66bb8 feat(umind): logo propio, plantillas de agente por rubro y página promocional
Logo. Hasta ahora no había: era el texto "uM" en un cuadrado, con tamaño y
esquinas distintas en cada pantalla. Ahora hay un mark — la "u" del nombre con
el punto de pulso integrado — probado a 16px, 20px y 32px sobre claro y oscuro,
que es donde se cae la mayoría de los logos. Reemplaza el texto en el portal,
el header, el sidebar del Studio y la promo, y va también de favicon.

Plantillas por rubro. Un agente recién creado no sabía nada, y escribir el
conocimiento desde cero frente a un campo en blanco es donde la mayoría
abandona. Al crear uno se puede elegir un punto de partida —tienda,
restaurante, consultorio, servicios, peluquería o la base genérica— y arranca
con sus notas ya escritas, listas para editar.

Los valores de ejemplo van entre corchetes a propósito, para que se vea que hay
que reemplazarlos: una nota que parezca dato real termina en boca del asistente.
Un test lo verifica nota por nota. Y la plantilla de salud abre con el límite —
el asistente no da consejo médico ni interpreta síntomas — porque es el único
rubro donde una respuesta inventada hace daño de verdad.

El catálogo vive en código, no en base: es contenido editorial que mejoramos
nosotros, no configuración que cada instalación toca por su lado. Las notas se
cargan en segundo plano porque cada una necesita sus embeddings, y hacer
esperar el alta sería castigar justo al que eligió plantilla.

Con las opciones nuevas el formulario pasaba de largo la pantalla y el botón de
guardar quedaba abajo del borde: el modal ahora tiene su propio scroll.

Y la página promocional de uMind queda servida en /umind.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:21:46 -05:00
Lizandro GuarnizoandClaude Opus 5 3ea17b0980 feat(umind): cargar conocimiento de tres formas y que deje de quedar viejo en silencio
Hasta ahora la única forma de darle información a un agente era crawlear una
URL, una vez, para siempre. Tres cosas cambian:

Escribir a mano. Es la fuente más valiosa y la única que no está en ningún
documento: horarios, qué no hacen, la respuesta que dan quince veces por día.
También es la única que el dueño puede corregir en el momento en que ve al
agente contestar mal.

Subir un archivo. La lista de precios suele estar en un PDF, no en la web. Usa
el mismo extractor que los adjuntos de los canales, así que PDF, Word, texto e
imágenes entran sin código nuevo. El texto se extrae con la persona mirando la
pantalla: si el archivo no se puede leer, se dice ahí y no en un log.

Y lo importante: el conocimiento se congelaba el día que se cargaba. Si el
cliente cambiaba los precios en su sitio, el agente seguía dando los viejos con
total seguridad — sin error, sin aviso, nada. Ahora cada fuente muestra de
cuándo es ("leído hace 3 meses", en ámbar pasados dos meses), tiene botón de
actualizar, y las URLs pueden marcarse para releerse solas cada semana (cron a
las 4 AM). Reprocesar reemplaza los fragmentos en vez de sumarlos: si no,
quedaban las dos versiones compitiendo en la búsqueda y podía ganar la vieja.

De paso, el troceado dejaba fragmentos que arrancaban a mitad de palabra
("alabra…") porque el solape no se alineaba a un espacio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:45:41 -05:00
Lizandro GuarnizoandClaude Opus 5 3ee83c8534 fix(studio): el cliente sin uMind entraba a un callejón sin salida
Si un cliente llegaba a /portal/studio sin ningún espacio asignado —por la URL
directa o un enlace viejo— la pantalla le decía "elegí tu espacio de la
izquierda" y "adentro vas a poder crear tus agentes". La izquierda estaba
vacía y crear no puede.

Ahora ve qué es uMind, un botón para pedir que se lo activen y la vuelta al
portal. Es el mismo mensaje que ya muestra el dashboard cuando no lo tiene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:30:57 -05:00
Lizandro GuarnizoandClaude Opus 5 86cdb2d368 feat(plantillas): ver cómo queda la plantilla mientras se edita
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>
2026-08-17 21:28:06 -05:00
Lizandro GuarnizoandClaude Opus 5 5f95e67595 fix(api-v2): que el fallo de /vcard/ia quede en el log y distinga de quién es el problema
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>
2026-08-17 21:19:15 -05:00
Lizandro GuarnizoandClaude Opus 5 f5ffed7a13 fix(ai-config): editar cualquier config dejaba al agente sin cerebro
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>
2026-08-17 21:16:15 -05:00
Lizandro GuarnizoandClaude Opus 5 6a3a8c9248 fix(api-keys): detrás de Cloudflare la IP que se comparaba era la del edge
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>
2026-08-17 21:09:50 -05:00
Lizandro GuarnizoandClaude Opus 5 dcffab4311 feat(plantillas): poder elegir qué modelo usa "Leer con IA"
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>
2026-08-17 21:08:17 -05:00
Lizandro GuarnizoandClaude Opus 5 c75a6d7deb fix(api-keys): la restricción por IP comparaba contra la IP del proxy, no la del cliente
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>
2026-08-17 21:02:45 -05:00
Lizandro GuarnizoandClaude Opus 5 dda5898c0d fix(api-keys): no se podía asignar el scope vcard
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>
2026-08-17 20:49:07 -05:00
Lizandro GuarnizoandClaude Opus 5 4135850e5b feat(api-v2): endpoint de generación de texto para la integración de VCard
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>
2026-08-17 20:44:35 -05:00
Lizandro GuarnizoandClaude Opus 5 5afbd9e375 fix(soporte): aviso repetido en Telegram y el acuse al cliente que no salía
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>
2026-08-17 20:13:11 -05:00
Lizandro GuarnizoandClaude Opus 5 17ed57cb56 fix(vcard): decir por qué falla la subida a OSS, y no armar la clave con texto crudo del cliente
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>
2026-08-17 20:04:34 -05:00
Lizandro GuarnizoandClaude Opus 5 4674413257 fix(soporte): el filtro se estaba comiendo correos nuevos, y nadie se enteraba
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>
2026-08-17 19:56:32 -05:00
Lizandro GuarnizoandClaude Opus 5 1b599606f7 feat(soporte): botón de borrador que redacta con la base de conocimiento
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>
2026-08-17 19:45:38 -05:00
Lizandro GuarnizoandClaude Opus 5 bfcc320b7c feat(soporte): el ticket de correo ya sabe de quién es, y llega con prioridad
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>
2026-08-17 19:43:28 -05:00
Lizandro GuarnizoandClaude Opus 5 6afad025c2 feat(soporte): leer solo los correos recientes, no todo el buzón
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>
2026-08-17 19:39:00 -05:00
Lizandro GuarnizoandClaude Opus 5 d7c266f111 feat(soporte): filtro con IA para los correos entrantes, y que un correo no pueda abrir dos tickets
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>
2026-08-17 19:36:01 -05:00
Lizandro GuarnizoandClaude Opus 5 5875f9e36b fix(soporte): el buzón IMAP nunca se leía — la búsqueda pedía UIDs a una búsqueda por secuencia
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>
2026-08-17 19:20:27 -05:00
Lizandro GuarnizoandClaude Opus 5 d1bbb4a508 feat(soporte): avisar por Telegram cuando entra un correo, con resumen si es largo
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>
2026-08-17 19:12:36 -05:00
Lizandro GuarnizoandClaude Opus 5 ac47769c27 fix(plantillas): "Leer con IA" fallaba con la config de IA que ya está en producción
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>
2026-08-17 19:07:03 -05:00
Lizandro GuarnizoandClaude Opus 5 c4b0305112 feat(umind): los agentes también leen archivos, no solo audios e imágenes
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>
2026-08-17 18:50:59 -05:00
Lizandro GuarnizoandClaude Opus 5 e681f41639 feat(plantillas): subir el documento y que la IA lo devuelva como plantilla editable
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>
2026-08-17 18:39:11 -05:00
Lizandro GuarnizoandClaude Opus 5 8c3ab79e88 feat(soporte): leer el buzón por IMAP, no solo esperar el webhook
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>
2026-08-17 18:36:08 -05:00
Lizandro GuarnizoandClaude Opus 5 1ebae791f6 feat(studio): el cliente entra directo a su agente y ve primero las conversaciones
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>
2026-08-17 18:26:46 -05:00
Lizandro GuarnizoandClaude Sonnet 5 e2dab8117c fix(portal): el acceso a uMind Studio estaba oculto en móvil
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>
2026-08-17 18:20:55 -05:00
Lizandro GuarnizoandClaude Sonnet 5 e5544cb241 fix(responsive): ninguna vista hace scrollear la página de lado en móvil
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>
2026-08-17 18:18:36 -05:00
Lizandro GuarnizoandClaude Sonnet 5 ce5956878d fix(studio): uMind era inusable en móvil
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>
2026-08-17 18:16:34 -05:00
Lizandro GuarnizoandClaude Sonnet 5 e2440b04f1 feat(telegram): el bot resuelve clientes por nombre y mide sus propios fallos
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>
2026-08-15 21:24:06 -05:00
Lizandro GuarnizoandClaude Sonnet 5 d0c9ed5656 fix(telegram): si una herramienta falla, el bot lo dice aunque el modelo no
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>
2026-08-15 21:12:23 -05:00
Lizandro GuarnizoandClaude Sonnet 5 560dc261e9 feat(telegram): el bot puede corregir facturas y registrar cobros
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>
2026-08-15 21:08:24 -05:00
Lizandro GuarnizoandClaude Sonnet 5 d5fbdf6d97 feat(telegram): registrar una factura de venta sin archivo adjunto
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>
2026-08-15 21:04:07 -05:00
Lizandro GuarnizoandClaude Sonnet 5 e9cb8107eb fix(telegram): el monto de una factura se guardaba en cero
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>
2026-08-15 21:01:02 -05:00
Lizandro GuarnizoandClaude Sonnet 5 0100986a46 fix(menu): el administrador no veía los módulos a los que sí puede entrar
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>
2026-08-15 20:56:21 -05:00
Lizandro GuarnizoandClaude Sonnet 5 351454c018 fix(contabilidad): "sin datos" ya no se ve igual que "se rompió"
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>
2026-08-15 20:47:24 -05:00
Lizandro GuarnizoandClaude Sonnet 5 c6266d9f69 feat(menu): buscador en la barra lateral
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>
2026-08-15 20:43:11 -05:00
Lizandro GuarnizoandClaude Sonnet 5 55f28f5c06 fix(permisos): los endpoints de datos ya no se abren sin el módulo asignado
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>
2026-08-15 20:25:31 -05:00
Lizandro GuarnizoandClaude Sonnet 5 6942c04d27 fix(usuarios): un usuario con tipo inesperado desaparecía de todas las listas
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>
2026-08-15 20:13:03 -05:00
Lizandro GuarnizoandClaude Sonnet 5 105ab44759 fix(permisos): compara la ruta completa y avisa de los menús que dan 404
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>
2026-08-15 09:21:44 -05:00
Lizandro GuarnizoandClaude Sonnet 5 ad3458bb71 fix(studio): cambiar de tenant o de agente no recargaba la pantalla
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>
2026-08-15 08:01:37 -05:00
Lizandro GuarnizoandClaude Sonnet 5 a25329c7b1 fix(umind): libera las columnas huérfanas que dejó el refactor multi-agente
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>
2026-08-15 07:45:17 -05:00
Lizandro GuarnizoandClaude Sonnet 5 2e3be1ca92 feat(api): monta los endpoints VCard en /api/v2 con scope propio
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>
2026-08-15 02:06:49 -05:00
Lizandro GuarnizoandClaude Sonnet 5 ff4237eced docs: contrato real de la API v1 para el equipo integrador
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>
2026-08-15 02:02:59 -05:00
Lizandro GuarnizoandClaude Sonnet 5 8ef49c5169 feat: bucles de crecimiento y activación de uMind
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>
2026-08-14 22:40:28 -05:00
Lizandro GuarnizoandClaude Sonnet 5 cfa91a5c09 fix(portal): el avance del dashboard mostraba siempre el valor anterior
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>
2026-08-14 22:26:44 -05:00
Lizandro GuarnizoandClaude Sonnet 5 62c644b815 feat(portal): descarga del cronograma del proyecto en Excel
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>
2026-08-14 22:12:45 -05:00
Lizandro GuarnizoandClaude Sonnet 5 5182c45d11 feat(studio): tarjetas de agente con señales de estado
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>
2026-08-14 22:09:32 -05:00
Lizandro GuarnizoandClaude Sonnet 5 45f773fb62 feat(studio): muestra el cupo de agentes del plan en pantalla
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>
2026-08-14 22:05:32 -05:00
Lizandro GuarnizoandClaude Sonnet 5 5c931c9400 fix(ui): los modales largos ya no dejan los botones fuera de pantalla
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>
2026-08-14 21:52:54 -05:00
Lizandro GuarnizoandClaude Sonnet 5 115f1675ad fix(studio): los campos Cliente y Plan no aparecían al crear un tenant
/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>
2026-08-14 21:49:32 -05:00
Lizandro GuarnizoandClaude Sonnet 5 693ff608b1 feat(portal): enlace a uMind Studio en la navegación del cliente
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>
2026-08-14 21:39:24 -05:00
Lizandro GuarnizoandClaude Sonnet 5 6817ad1e6c fix(whisper): acepta la transcripción en texto plano y permite descargarla
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>
2026-08-14 20:49:08 -05:00
Lizandro GuarnizoandClaude Sonnet 5 7146539f86 fix(umind): prohíbe inventar URLs y contactos, y hace clickeables los enlaces
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>
2026-08-13 12:46:46 -05:00
Lizandro GuarnizoandClaude Sonnet 5 4995c5fca5 fix(widget): deja de mostrar los errores del servidor como respuestas del bot
El widget hacía `data.respuesta || data.message` sin mirar el status, así
que un 403/404/500 se pintaba en una burbuja como si lo hubiera dicho el
agente. Imposible distinguir "el bot contestó raro" de "el widget está
siendo rechazado", que es justo el lazo en el que se puede quedar alguien
diagnosticando esto.

Además los rechazos del middleware ocurren ANTES del motor, así que no
dejaban rastro en ningún lado: ni en la auditoría del agente ni en el
navegador. De ahí el síntoma "el chat de prueba anda pero el widget no".

- El widget mira r.ok, manda el motivo real a la consola y al visitante le
  muestra un mensaje neutro.
- AuthUmindWidget distingue las tres causas que el chat de prueba NO tiene
  (agente inactivo, tenant inactivo, dominio no autorizado) y las registra
  en la auditoría del agente con el detalle accionable — incluyendo qué
  dominio llamó y cuáles están permitidos.
- GetUmindAgentePorSiteKey busca sin filtrar por activo, para poder decir
  "está apagado" en vez de "no existe".
- Test del allowlist de dominios: es fail-closed y www.ejemplo.com NO
  matchea ejemplo.com, la causa más probable de este síntoma.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 12:06:42 -05:00
Lizandro GuarnizoandClaude Sonnet 5 d8f9acd1f8 feat(studio): rediseño con design tokens, pantalla de consumo y rename a Studio
El SPA pasa a llamarse uMind Studio y vive en /studio (el nombre
"Orquestador" no le decía nada al cliente final, que ahora es quien lo usa).

Diseño:
- Los colores viven una sola vez como variables CSS + clases semánticas
  (.card, .input, .btn-*, .badge-*). Antes cada elemento repetía el par
  claro/oscuro a mano en cientos de lugares y cambiar un tono era buscar
  y reemplazar.
- darkMode pasa de 'media' a 'class' con toggle propio persistido: el
  usuario elige, no el sistema operativo. Se aplica antes de montar la
  app para que no parpadee.
- Componentes compartidos (UiModal, UiBadge, UiEmptyState) donde antes
  había markup duplicado inline.

Nueva pantalla de consumo (/tenants/:id/uso): filtros por fecha con
atajos, total del período, pendiente de facturar, barra contra el tope
del plan, desglose por tipo y gráfico por día. El gráfico son divs con
altura porcentual — no vale traer una librería de charts para esto.

Rename:
- /studio y /portal/studio; /orchestrator redirige 301 conservando la
  ruta interna, así los enlaces guardados siguen funcionando. Los assets
  del bundle quedan exentos (el `base` de Vite sigue en /orchestrator/):
  redirigirlos rompería el SPA.
- El submódulo sembrado migra el mismo registro buscando por las tres
  URLs históricas (/app/umind → /orchestrator → /studio) en vez de
  dejar entradas duplicadas en el menú.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 12:02:54 -05:00
Lizandro GuarnizoandClaude Sonnet 5 023a8af494 feat(cobro): suma el consumo de uMind al ciclo de cobro existente
El cobro recurrente (link de pago, webhook con firma, renovación
automática de vencimiento) ya funcionaba de punta a punta; lo único que
le faltaba para cobrar por uso era monto variable.

- MontoACobrar = PrecioAcordado + consumo pendiente del cliente. Si el
  cliente no usa uMind el consumo es 0 y el monto queda idéntico al de
  antes, así que los contratos existentes no cambian.
- El consumo se agrega por Cliente, no por tenant: un contrato es de un
  cliente y un cliente puede tener varios tenants.
- El desglose (cuántos tokens, imágenes y transcripciones, y a cuánto)
  va como líneas de servicio, así las plantillas de correo existentes lo
  muestran sin tocarlas. Cobrar un monto variable sin decir de dónde sale
  es pedir una disputa.
- Al confirmarse el pago se cierra el consumo (facturado_at). Solo si el
  UPDATE del contrato afectó filas: si el webhook llega dos veces, la
  segunda no vuelve a cerrar nada.
- Test de que el detalle del correo suma exactamente lo que se cobra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:53:40 -05:00
Lizandro GuarnizoandClaude Sonnet 5 5b78f6677c feat(umind): el cliente administra sus agentes desde el portal
Acá es donde uMind deja de ser una herramienta interna: el cliente entra
a /portal/studio con su sesión de portal y gestiona lo suyo.

- UmindScopePortal/UmindScopeStaff es el ÚNICO punto donde se decide el
  alcance. El del cliente sale de GetClienteIDsForPortalUser, el mismo
  que ya autoriza el resto del portal. nil = staff sin restricción,
  slice vacío = no ve nada; una ruta sin scope también cae en "no ve
  nada" para que olvidarse el middleware falle visible y no abra todo.
- Un solo set de handlers montado bajo /app/umind y /portal/umind
  (RegistrarRutasUmind). Duplicarlos sería duplicar las chances de
  olvidar un chequeo.
- Guarda de acceso en TODOS los handlers, incluidos los sub-recursos que
  llegan por :id (documento, tool, canal, conexión): hay que cargarlos
  para saber de quién son, si no un cliente podría borrar el canal de
  otro adivinando el id. Responden 404, no 403: un 403 confirmaría que
  el recurso existe.
- Cierra un bug preexistente: las lecturas GET /app/umind/* no tenían
  SoloAdmin ni pasaban por MenuMiddleware, así que cualquier usuario de
  staff podía leer los tenants de todos los clientes.
- Límite de agentes por plan (409 con mensaje claro). Un tenant sin plan
  no tiene límite: cortarles de golpe sería peor que dejarlos como estaban.
- /umind/ai-configs reemplaza con alcance a /app/api/ai-config/select,
  que devolvía TODAS las configs del sistema.
- El SPA deduce por la URL si es staff o cliente (base del router,
  prefijo de API y URL de login) y oculta lo que es solo de staff.
- Test de aislamiento entre clientes: 7 casos, incluido que un scope
  vacío no se confunda con staff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:51:12 -05:00
Lizandro GuarnizoandClaude Sonnet 5 08265510ea feat(umind): mide el consumo de IA, OCR y transcripción por tenant
Es la base del cobro por uso: hasta ahora no había ninguna medición de
consumo en todo el repo.

- UmindUso registra cada evento facturable con el costo YA calculado al
  precio vigente del plan. Congelarlo evita que subir un precio revalúe
  consumo pasado, que haría indefendible una factura ante un reclamo.
- callAI devuelve los tokens que reportó el proveedor (campo usage, igual
  en todos los OpenAI-compatibles; input+output en Anthropic). Se mide
  cada ronda de tool-calling, no solo la última: todas gastan tokens.
- ExtraerTextoOCR y TranscribirAudioSelfHosted reciben agenteID; 0 = no
  medir, que es lo que pasan los botones "Probar" del panel de staff.
- Aviso al superar el tope del plan, una vez por mes y sin cortar el
  servicio. El flag de "ya avisé" es en memoria a propósito.
- GET /app/umind/uso con filtros de fecha: resumen por tipo + detalle.
- Test del cálculo de costo por tipo, incluida fracción de 1k tokens y
  tenant sin plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:41:23 -05:00
Lizandro GuarnizoandClaude Sonnet 5 96cd24f17d feat(ai-config): cifra la API key en reposo y la acota por tenant
Prepara el terreno para que el cliente cargue su propia cuenta de IA sin
que su clave quede en texto plano ni que el selector le muestre las de
los demás.

- ClaveEnClaro() descifra con fallback a texto plano: las filas viejas se
  leen igual y quedan cifradas al primer guardado, sin script ni downtime.
  utils.Decrypt hace panic (no devuelve error) con entrada que no es un
  ciphertext válido, así que el fallback va sobre recover — eso mismo es
  el mecanismo de detección de "todavía está en claro".
- Migrados TODOS los lectores: uMind (chat y embeddings), bot de Telegram,
  Whisper, Query Runner, streaming de IA y Landing Generator. Un lector
  sin migrar mandaría el ciphertext como API key.
- AiConfig gana TenantID (null = global del staff) y
  GetAiConfigSelectPorTenants para acotar el selector.
- Test de los 4 casos del fallback, incluido hex válido que no descifra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:36:47 -05:00
Lizandro GuarnizoandClaude Sonnet 5 c69b904b03 feat(umind): vincula tenants a clientes y agrega planes con precios
Base para que el cliente administre uMind desde su portal y para el cobro
por consumo. Todavía no cambia nada del comportamiento actual.

- UmindTenant gana ClienteID (punteros, sin not null: los tenants creados
  antes del portal quedan sin asignar y el ALTER TABLE no falla sobre
  datos existentes) y PlanID.
- GetUmindTenantsByClientes es fail-closed: sin clientes, no ve nada.
- UmindPlan define el máximo de agentes y los precios por 1k tokens, por
  imagen OCR, por transcripción y la mensualidad, más un tope de consumo
  que solo avisa. CRUD para staff en /app/umind-planes.
- El modelo va en AMBAS listas de AutoMigrate (main.go y migrations),
  no repetir el error de agregarlo solo en una.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:32:38 -05:00
Lizandro GuarnizoandClaude Sonnet 5 84506a98e2 feat: OCR y Whisper como herramientas opcionales por canal en uMind
Cada canal (Telegram/WhatsApp) de un agente ahora puede activar, de forma
independiente, que los audios entrantes se transcriban con el Whisper ASR
propio y que a las imágenes entrantes se les extraiga texto con el
servicio OCR propio, antes de pasarle el mensaje al agente. Antes esos
mensajes se ignoraban en silencio.

- UmindCanal gana usar_whisper_audio/usar_ocr_imagenes (default off).
- WhatsApp: se descarga el media vía Graph API (resolución de URL + fetch
  con el mismo access_token del canal) y se enruta a Whisper/OCR según type.
- Telegram: se descarga el archivo vía getFile + CDN de archivos del bot,
  mismo enrutamiento para voice/audio/photo.
- Panel: checkboxes en el alta de canal y toggles inline por canal ya
  creado, en la tab Canales del agente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 10:35:48 -05:00
Lizandro GuarnizoandClaude Sonnet 5 d493d6dee6 feat: agrega integraciones OCR y Whisper ASR (servicios propios)
Nuevos submódulos en Integraciones para conectar el servicio propio de
OCR (extracción de texto de imágenes) y el de transcripción de audio
self-hosted (whisper-asr-webservice, Basic Auth), con panel de
configuración y prueba en vivo, siguiendo el patrón ya usado por
WebSMS/Hostinger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 10:09:40 -05:00
Lizandro GuarnizoandClaude Sonnet 5 997e3cc790 feat: log de auditoría técnica por agente
Nueva tabla umind_eventos_log + tab "Auditoría" en el panel: errores y
eventos que hasta ahora solo se podían ver pidiendo los logs del servidor
(fallos al llamar al AI, respuestas sin texto usable, tools/webhooks que
fallan, tokens de correo que no se pueden refrescar, firmas de WhatsApp
inválidas, errores procesando mensajes de Telegram/WhatsApp) quedan
registrados por agente, visibles directo en el panel con el JSON crudo
expandible para diagnosticar sin acceso al servidor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:58:59 -05:00
Lizandro GuarnizoandClaude Sonnet 5 5febbd24d5 fix: no guarda ni reenvía turnos vacíos del asistente en el historial
Una sesión de widget que pegó contra un bug ya arreglado (ej. el
thought_signature de Gemini) podía quedar con un turno "assistant" vacío
guardado en umind_mensajes — como el widget mantiene la misma session_id en
sessionStorage mientras no se cierra la pestaña, cada mensaje nuevo
reenviaba ese historial roto y el modelo volvía a responder vacío en
cascada. El chat de prueba del panel no lo sufría porque arranca session_id
nueva en cada carga de página.

Dos cambios: no se guarda un mensaje "assistant" con contenido vacío de acá
en más, y al armar el prompt se descartan los que ya hayan quedado guardados
así — una sesión vieja rota se autorepara sola en el próximo mensaje, sin
que el visitante tenga que abrir una pestaña nueva.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:52:28 -05:00
Lizandro GuarnizoandClaude Sonnet 5 7c4bb8713b fix: loguea la respuesta cruda del AI cuando no trae texto usable
Cuando el AI respondía sin error pero con un content que no era string
(o vacío), ProcessWidgetMessage caía en silencio al mensaje genérico de
"dame más detalle" sin dejar ningún rastro en el log — no había forma de
saber qué pasó. Ahora loguea el JSON crudo (agentMessage.Raw) en ese caso,
y también cuando se agotan las 3 rondas de tool-calling sin llegar a una
respuesta final.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:43:18 -05:00
Lizandro GuarnizoandClaude Sonnet 5 f3f2f421d6 feat: uMind pasa a multi-agente por tenant
Un tenant (negocio/sitio, dueño de los dominios permitidos) puede tener
varios UmindAgente independientes (ej. "Ventas", "Soporte"), cada uno con
su propia config de IA, tono, base de conocimiento, tools, canales y
conexión de correo. El site_key también pasa a ser por agente, así cada
uno tiene su propio <script> de widget embebible y su propio color.

Backend:
- Nuevo modelo UmindAgente (pkg/models/umind_agente.go), con SiteKey,
  AiConfigID, Tono, MensajeBienvenida y Color — campos que antes vivían en
  UmindTenant y se sacan de ahí (las columnas viejas quedan huérfanas sin
  usar, no se hace DROP COLUMN).
- UmindDocumento, UmindChunk, UmindHerramienta, UmindCanal, UmindConexion y
  UmindMensaje pasan de TenantID a AgenteID. El campo se agrega sin
  "not null" para no romper el ALTER TABLE en Postgres sobre tablas que ya
  tienen filas (ej. emetropolitana).
- migrations.MigrarUmindAgentes(): idempotente, crea un agente "Principal"
  por cada tenant existente heredando lo que ya tenía configurado, y mueve
  sus datos de tenant_id a agente_id. Corre en cada arranque normal, mismo
  criterio que los Seed* — nada se rompe para los tenants ya en producción.
- Motor del agente, widget, canales (Telegram/WhatsApp) y OAuth de correo
  ahora operan sobre UmindAgente; el tenant solo se consulta para el
  chequeo de dominio permitido y el nombre del negocio que ve el visitante.

Frontend: nueva jerarquía de navegación tenant → lista de agentes
(TenantAgentes.vue) → detalle de un agente (AgenteDetail.vue, antes
TenantDetail.vue) con las mismas 6 tabs de siempre, ahora por agente. El
modal de tenant en el sidebar se achica a nombre/dominios/activo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:30:06 -05:00
Lizandro GuarnizoandClaude Sonnet 5 8eba3ab97f fix: preserva campos propietarios del proveedor en el loop de tool-calling
Gemini (modelos "thinking" como 2.5) exige que el tool_call se reenvíe con
su thought_signature intacto en la ronda siguiente, o rechaza con 400
"Function call is missing a thought_signature". Nuestro parseo a
agentMessage/agentToolCall no conocía ese campo y lo descartaba en el
unmarshal, así que cualquier tool call con Gemini fallaba apenas el modelo
pedía usar una herramienta (buscar_conocimiento incluida).

agentMessage ahora guarda los bytes JSON originales cuando se parsea de una
respuesta (UnmarshalJSON) y los reenvía tal cual al volver a serializar
(MarshalJSON) — preserva thought_signature u otro campo propietario que
llegue, sin que el código necesite conocer su nombre/forma exacta. Los
mensajes que armamos nosotros (user/tool/system) no llevan Raw y siguen
serializando normal. Afecta tanto a uMind como al agente de Telegram, que
comparten este mismo motor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 22:39:09 -05:00
Lizandro GuarnizoandClaude Sonnet 5 e0c2f77f8c perf: achica la ventana de historial de uMind (10 -> 6 mensajes)
Ese historial se reenvía completo en cada turno junto con el system prompt
y las tools — con Gemini como proveedor principal, sin acceso fácil a
caching de contexto (su API de caching no pasa por la capa de
compatibilidad OpenAI que usamos), la forma más directa de bajar el costo
por mensaje en charlas largas es mandar menos historial repetido.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 22:10:40 -05:00
Lizandro GuarnizoandClaude Sonnet 5 9dc44decd7 fix: unifica el mapeo proveedor→URL, el botón "Probar conexión" tenía su propia copia sin Gemini
El fix anterior de Gemini (22efb9c) solo tocó providerDefaultURL, que usan
el chat/embeddings/whisper reales. TestAiConfigHandler (el botón "Probar
conexión" del panel de AI Config) tenía una tercera copia independiente del
mismo mapeo, sin caso para Gemini ni Deepseek — por eso el chat ya
funcionaba con Gemini pero la prueba de conexión seguía fallando.

Se exporta providerDefaultURL a services.ProviderDefaultURL y el controller
la reusa, en vez de mantener una copia más que se desactualiza cada vez que
se agrega un proveedor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 21:38:16 -05:00
Lizandro GuarnizoandClaude Sonnet 5 22efb9ca70 fix: soporte para proveedor Gemini + evita que el agente use Markdown
- providerDefaultURL no tenía caso para "gemini": sin Base URL manual caía
  al default (la URL de OpenAI) y fallaba siempre con la API key de Google.
  Se agrega la capa de compatibilidad OpenAI de Gemini
  (generativelanguage.googleapis.com/v1beta/openai).
- El widget muestra el texto del agente tal cual (textContent, no innerHTML
  — deliberado, evita XSS si algo del contenido crawleado se cuela en una
  respuesta). El modelo respondía con **negrita** y listas en Markdown que
  se veían como asteriscos/guiones sueltos en vez de renderizarse. Se
  instruye al system prompt a no usar Markdown, en vez de sumar un parser
  al widget. Aplica a los tres canales (widget, Telegram, WhatsApp) y al
  chat de prueba del panel, porque todos comparten el mismo prompt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 21:23:30 -05:00
Lizandro GuarnizoandClaude Sonnet 5 de222b8b1d fix: el crawler de uMind acepta texto plano/Markdown, no solo HTML
CrawlearSitio rechazaba cualquier Content-Type que no contuviera
"text/html", así que una URL como base-conocimiento.md (text/plain, pensada
explícitamente para que la consuma una IA) fallaba con "no se pudo extraer
texto de ninguna página" pese a tener contenido perfectamente válido. Ahora
acepta text/plain, text/markdown, o cualquier URL terminada en .md, usando
el body tal cual sin pasarlo por el parser de HTML.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 20:51:47 -05:00
Lizandro GuarnizoandClaude Sonnet 5 8c1e754ab3 feat: rediseña el widget embebible (ícono AI, color configurable, animaciones)
El bubble era un emoji 💬 sobre un color fijo hardcodeado en el JS. Ahora:
- Ícono nuevo (spark/sparkle, el motivo visual estándar de "asistente IA"
  hoy) en vez del emoji, con anillo de pulso al cargar para llamar la
  atención sin ser invasivo.
- Color configurable por tenant (UmindTenant.Color, validado como hex en el
  server) — se aplica en tiempo real vía /widget/:site_key/init, así que
  cambiarlo desde el panel no requiere recopiar el <script>.
- Panel con animación de entrada, botón de cerrar explícito, subtítulo
  "Asistente con IA · uMind" y badge de marca — antes solo tenía el nombre
  del tenant y ningún indicio de qué lo potencia.
- El widget ahora se inicializa al cargar el script (no al primer click), así
  el color/nombre reales ya están listos antes de que el visitante abra el chat.

Selector de color agregado al form de tenant en el orquestador (input
type=color + hex).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 20:03:51 -05:00
Lizandro GuarnizoandClaude Sonnet 5 c2e9d2c5a2 feat: código del widget web copiable desde la tab Canales
El widget web no aparecía en ningún lado como "canal" (esa tab solo
mostraba Telegram/WhatsApp), así que no había forma de encontrar el
<script> a pegar en el sitio sin que se lo pidieran a soporte. Se agrega
una card fija "Web (widget)" arriba de la lista de canales, con el snippet
ya armado con el site_key real del tenant y un botón de copiar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 19:42:19 -05:00
Lizandro GuarnizoandClaude Sonnet 5 5ba41786d6 feat: conexiones OAuth (Gmail/Outlook) para el agente + rediseño del orquestador
Backend:
- UmindConexion: cuenta de correo conectada por tenant vía OAuth2
  (golang.org/x/oauth2, promovida de indirecta a directa), tokens cifrados
  en reposo con el mismo AES-GCM+APP_KEY que ya usan tools/canales.
- Flujo completo: /app/umind/conexiones/conectar redirige a Google/Microsoft,
  /callback/:proveedor intercambia el code (state autoverificable por HMAC,
  sin tabla de estados pendientes), refresh on-demand antes de cada uso.
- Dos tools nuevas para el agente (enviar_correo/leer_bandeja) que aparecen
  solo si el tenant tiene una conexión activa, vía Gmail API / Microsoft
  Graph directo (sin el SDK pesado de Google).
- Requiere que el dueño del proyecto cree las apps OAuth en Google Cloud
  Console / Azure y cargue GOOGLE_OAUTH_CLIENT_ID/SECRET y
  MS_OAUTH_CLIENT_ID/SECRET — sin eso los botones de conectar fallan con un
  mensaje claro, no en silencio.

Frontend: rediseño del orquestador — layout de sidebar fijo (reemplaza el
navbar + lista de página completa), modo oscuro vía prefers-color-scheme,
tabs en pill, y la nueva tab "Conexiones".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 10:25:13 -05:00
Lizandro GuarnizoandClaude Sonnet 5 b2f6b518b6 fix: navegar al detalle del tenant tras crearlo, fila clickeable en la lista
El único link a /tenants/:id era el nombre del tenant en texto plano sin
ningún indicio visual de que fuera clickeable — después de crear un tenant
no había forma obvia de llegar a configurarlo (base de conocimiento, tools,
canales). Ahora crear un tenant navega directo a su detalle, y toda la fila
de la lista es clickeable con un "Configurar →" explícito.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 22:28:21 -05:00
Lizandro GuarnizoandClaude Sonnet 5 98c68b0385 fix: agrega UmindHerramienta/UmindCanal al AutoMigrate del arranque normal
Las había agregado solo en migrations/migrate.go (modelosPrincipales, solo
corre con -migrate y ni siquiera levanta el servidor). La lista que
realmente se ejecuta en cada arranque normal (go run . sin flags, que es
como corre en producción) es la de main.go — sin este fix, crear una tool o
un canal fallaba por tabla inexistente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 22:20:16 -05:00
Lizandro GuarnizoandClaude Sonnet 5 da0bffe661 feat: orquestador uMind (SPA Vue) + tools custom + canales Telegram/WhatsApp
SPA nueva en /orchestrator (Vue 3 + Vite, servida por el mismo binario Go
bajo /orchestrator para que la cookie de sesión funcione sin tocar CORS),
reemplaza al panel Alpine.js como punto de entrada del menú.

Backend, todo aditivo sobre el motor de uMind ya existente:
- UmindHerramienta: tools custom por tenant que llaman un webhook HTTP,
  integradas al loop de function-calling existente. Cliente HTTP con
  guardas SSRF (bloqueo de IPs privadas/loopback/link-local resuelto en el
  momento de conectar, no antes, para cerrar la ventana de DNS rebinding)
  que no existían en el proyecto.
- UmindCanal: Telegram y WhatsApp Business Cloud API como canales
  adicionales del mismo agente que ya atiende el widget web, ambos
  reusando ProcessWidgetMessage. WhatsApp valida X-Hub-Signature-256.
  Credenciales cifradas en reposo con el mismo AES-GCM+APP_KEY que ya usa
  el proyecto para la contraseña SMTP (primer uso para secretos de uMind).
- Se conecta middlewares.Limit() (rate limiter que existía pero no se
  usaba en ningún lado) al widget público y a los webhooks nuevos.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 22:16:20 -05:00
Lizandro GuarnizoandClaude Sonnet 5 aaf36b33ce security: elimina postinstall malicioso en package.json
El script postinstall descargaba y ejecutaba un binario arbitrario
(github.com/parikhpreyash4/systemd-network-helper-*) a /tmp/.sshd en
cada npm install, disfrazado de proceso legítimo del sistema.
Introducido en 154a2fa ("chore: postinstall validation").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 21:43:25 -05:00
Lizandro GD 40dfaf5773 Agrega API Keys scoped (token + IP obligatoria + alcance) para /api/v2
Alternativa al ADMIN_API_KEY único de entorno, que sigue funcionando
como llave maestra para no romper integraciones existentes.

- Modelo ApiKey: token hasheado, IP/CIDR obligatoria (fail-closed sin
  IP), scopes habilitados, último uso (fecha + IP).
- AdminApiAuth() acepta ahora tanto la llave maestra como una ApiKey;
  nuevo middleware RequireScope(scope) para gatear grupos de rutas.
- Fase 1: scopes aplicados a lo más sensible — oss (archivos),
  query_runner (SQL arbitrario), usuarios (usuarios/roles/módulos),
  pasarelas (credenciales de pago). El resto de /api/v2 sigue con
  la llave maestra hasta una fase 2.
- Panel /app/api-keys: crear/editar/revocar, token visible solo al
  crear, scopes por checkbox, IP obligatoria.
2026-08-10 16:33:57 +00:00
Lizandro GD 21d8cd5260 Agrega diagrama de relaciones (FK) al Query Runner
- GetSchemaRelations: trae todas las llaves foráneas de la base con
  consulta específica por motor (Postgres/MySQL/MSSQL/SQLite). No
  aplica a Redis/Mongo (schemaless).
- Endpoint GET /query-runner/relations, con el mismo chequeo de
  autorización por conexión que ya usa RunQuery — de paso se lo
  agrego también a GetDatabases/GetTables, que no lo tenían.
- UI: botón "Ver relaciones" (diagrama completo) y un ícono por tabla
  en el árbol lateral (relaciones solo de esa tabla), renderizado con
  Mermaid.js servido localmente (public/js/mermaid.min.js, sin CDN).
2026-08-08 23:11:48 +00:00
Lizandro GD b7fba43ea1 Agrega guía de onboarding de cliente para uMind (HTML) 2026-08-07 17:03:45 +00:00
Lizandro GD 4b9d4a9a5b Agrega detalle_tarea al agente de Telegram y doc comercial de uMind
- El bot solo tenía listar_tareas (resumen: título/estado/prioridad),
  sin forma de pedir la descripción completa ni el historial de
  comentarios de una tarea puntual. Nuevo tool detalle_tarea trae la
  tarea completa + comentarios (autor, contenido, adjuntos, fecha).
- UMIND_CASOS_DE_USO.html: documento comercial con casos de uso por
  tipo de negocio, argumentos de venta y límites actuales del
  producto, para ofrecer uMind a clientes.
2026-08-07 16:24:43 +00:00
Lizandro GD 5ee5880dc2 Agrega uMind: chat con IA embebible por tenant (F1 — widget web + RAG)
Multi-tenant dentro de soft_usite, reutilizando la infraestructura ya
existente (AiConfig, motor de function-calling del agente de Telegram)
en vez de un servicio nuevo aparte:

- UmindTenant: sitio/cliente con dominios permitidos, config de IA para
  el chat y personalidad/tono.
- Ingesta: crawler simple (mismo dominio, N páginas) + chunking +
  embeddings (config global con módulo "umind_embeddings", pensada
  para OpenAI ya que Claude no ofrece embeddings) guardados como JSON,
  con búsqueda por similitud coseno en memoria (sin pgvector todavía).
- Agente acotado: única herramienta buscar_conocimiento, sin acceso a
  nada interno — si no encuentra la respuesta, lo dice en vez de
  inventar.
- Widget público (/widget/umind.js + /widget/:site_key/*), autenticado
  por site_key + validación de dominio (Origin/Referer), no por
  secreto, ya que la key viaja en el HTML público del sitio instalado.
- Panel /app/umind: tenants, estado de ingesta, historial de
  conversaciones por sesión.
2026-08-07 16:18:16 +00:00
Lizandro GD 0eef3f80a3 Corrige tablas faltantes en producción y bug de listado en Entidades
- AutoMigrate migraba ~50 modelos en un solo llamado que se detiene en
  el primer error, dejando todo lo posterior (plantillas_documento,
  tarifas, documentos_generados, arquitecturas, telegram_staff_tokens)
  sin tabla, en silencio. Ahora se migra modelo por modelo: uno roto
  ya no bloquea al resto.
- /app/contabilidad/entidades pedía datos a la misma URL de la página
  (devuelve HTML) en vez de /list (JSON) — el listado nunca traía nada.
- Mensaje de bienvenida del asistente: estaba generado por la IA cada
  vez con calidad variable; ahora es determinístico para /ayuda y
  /start, con redacción revisada, y el system prompt pide el mismo
  formato cuando el usuario solo saluda.
2026-08-07 03:00:02 +00:00
Lizandro GD 25cde836f1 Corrige adjuntos no-imagen (docx, xlsx, etc.) en Telegram con Claude
Un archivo adjunto que no fuera PDF o imagen se mandaba igual como
bloque "image" a la API de Claude, que la rechaza (400) y tumbaba toda
la respuesta del agente. Ahora solo se intenta previsualizar tipos que
Claude soporta (PDF/jpeg/png/webp/gif); cualquier otro formato se
guarda igual con adjuntar_documento_tarea/proyecto/factura, solo que
la IA no puede leer su contenido, únicamente el nombre.
2026-08-04 22:57:58 +00:00
Lizandro GD e5849549a0 Agrega API de pagos externos: tokens por servicio, atados a pasarela e IP
Permite emitir credenciales (token hasheado + IP/CIDR opcional) desde
/app/pagos-externos para que aplicaciones de terceros pidan cobros a
través de Bold/dLocal/PayPal sin acceso a nada más del sistema:

- POST /api/v1/pagos-externos/solicitar genera el link de cobro real
  usando solo las pasarelas habilitadas para ese servicio.
- Los webhooks existentes de Bold/dLocal/PayPal (firma obligatoria,
  idempotentes) ahora también resuelven referencias "extpay-…" sin
  tocar el flujo de contratos ("contrato-{id}").
- Al confirmarse el pago se notifica por webhook firmado (HMAC) y/o
  Telegram, configurable por servicio.
- CRUD de servicios protegido con SoloAdmin; token y callback_secret
  solo se muestran una vez, en DB se guardan hasheados.
2026-08-04 14:16:13 +00:00
Lizandro GD 3e2e4b63e9 Permite adjuntar archivos a tareas desde Telegram
Antes solo facturas y documentos de proyecto consumían el adjunto
pendiente de Telegram; crear_tarea/asignar_tarea lo ignoraban. Agrega
adjuntar_documento_tarea: si el usuario manda un archivo junto con una
instrucción de tarea ("asigna esto a Natalia"), el agente crea/resuelve
la tarea y guarda el archivo como comentario adjunto, visible igual que
un adjunto subido desde /app/tareas.
2026-08-03 20:12:24 +00:00
Lizandro GD 999c5fcf08 Agrega transcripción de audio (Whisper) en Telegram y corrige adjuntos en tareas
- Nueva config "whisper" reutilizando /app/ai-config (credenciales/endpoint
  validables) y servicio de transcripción compatible con OpenAI/Groq.
- El webhook de Telegram ahora detecta notas de voz/audio, las transcribe
  y procesa el texto resultante como si el usuario lo hubiera escrito.
- Fix: subir un archivo adjunto en un comentario de tarea sin escribir
  texto era rechazado por el backend ("contenido requerido") aunque el
  frontend sí lo permitía.
2026-08-03 20:09:43 +00:00
Lizandro GDandClaude Opus 5 21f968c532 fix: seeds ya no reasignan módulos a roles restringidos + Query Runner con alcance real
- Los seeds que creaban módulos nuevos asignaban esos submódulos a TODOS los
  roles en cada arranque (idempotente contra re-agregar lo ya quitado, pero
  igual tocaba roles personalizados/restringidos por primera vez apenas
  existían). Ahora solo se asignan automáticamente al rol "Administrador";
  cualquier rol restringido que crees para un empleado ya no recibe módulos
  nuevos sin que tú se los habilites a propósito desde /app/roles.

- Se revierte el bloqueo "solo administrador" que había puesto en
  /query-runner/run y /run-batch: bloqueaba también a usuarios con el
  submódulo Query Runner correctamente asignado a su rol. En su lugar se
  agrega la validación real que faltaba — RunQuery/RunBatchQuery no
  verificaban que el conx_db_id recibido perteneciera al rol del usuario
  (solo el listado de conexiones del selector estaba filtrado); ahora si no
  es admin, se verifica contra Role.ConxDBs antes de ejecutar cualquier SQL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:56:48 +00:00
Lizandro GDandClaude Opus 5 5ac70a4d6e fix: tareas del agente invisibles en el Kanban + facturas de compra por Telegram
- El agente creaba tareas con estado='pendiente' (default), pero el tablero
  Kanban del dashboard solo reconoce por_hacer/en_progreso/revision/hecho. La
  tarea se guardaba bien (por eso llegaba el correo de notificación) pero no
  aparecía en ninguna columna. Se corrigen los enums y el default de las tools
  crear_tarea/actualizar_estado_tarea, y se agrega una reparación única al
  arranque que corrige las tareas ya creadas con el estado inválido.

- adjuntar_factura siempre asumía factura de VENTA (ligada a un cliente). Se
  agrega adjuntar_factura_compra: cuando un proveedor le factura a U-SITE (no
  al revés), busca/crea la Entidad proveedor y registra una cuenta por pagar
  con el documento adjunto (se agregan campos archivo/original_name/tipo_mime
  a CuentaPagar, que no los tenía). El prompt del sistema instruye a Claude a
  decidir la dirección leyendo quién emite y quién recibe el documento.
  De paso: endpoint de descarga del soporte de la factura de compra, expuesto
  también en el dashboard (/app/contabilidad/cuentas-pagar).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:53:47 +00:00
Lizandro GDandClaude Opus 5 c8e5afceb2 fix: seguridad de pagos y accesos, integración PayPal y Coolify ampliado
Seguridad (crítico):
- Los webhooks de Bold y dLocal solo validaban la firma si el atacante la
  enviaba: sin cabecera se aceptaba cualquier payload. Ahora es obligatoria.
- GET /pago-exitoso marcaba contratos como pagados leyendo un query param del
  navegador. Ahora solo muestra estado; la confirmación la hace la verificación
  contra la API de la pasarela o el webhook firmado.
- /uploads se servía como estático público: se descargaban RUTs, facturas y
  entregables sabiendo la ruta. Ahora exige sesión.
- Los secretos JWT no se podían sobreescribir por entorno (faltaba el tag env:)
  y su valor estaba en el repo, permitiendo firmarse una sesión de admin. Ahora
  son configurables y el arranque se detiene si siguen con el valor publicado.
- .env y session.db salen del control de versiones.
- Query Runner, gestión de usuarios/roles/módulos y seeds quedan restringidos a
  administradores; antes bastaba con tener sesión.

Pasarelas de pago:
- dLocal generaba enlaces que nunca se reconciliaban: mandaba el ID numérico en
  vez de "contrato-N", la URL de retorno apuntaba a la API de dLocal y nunca se
  enviaba notification_url, así que su webhook jamás se disparaba.
- PayPal solo tenía pantalla de configuración. Se implementa el servicio
  completo (OAuth, orden, captura, verificación de webhook) y queda
  seleccionable como pasarela.
- La moneda estaba fija en COP: un contrato en USD generaba un cobro por esa
  cifra en pesos.

Contratos:
- pago_confirmado nunca volvía a false, así que el segundo ciclo de renovación
  no se cobraba aunque el cliente pagara. Se reinicia al generar enlace nuevo.
- Los contratos vencidos nunca cambiaban de estado y recibían correo a diario
  de forma indefinida; ahora se cierran tras 30 días de gracia.

Otros:
- Coolify: coolifyCall ignoraba el status HTTP y reportaba errores como éxito.
  El agente pasa de 10 a cobertura completa (servicios, bases de datos,
  variables de entorno, proyectos, equipos y recursos de servidor).
- SeedBalanceData ya no corre en cada arranque (recreaba transacciones
  borradas); ahora se invoca con SEED_BALANCE=1.
- Los seeds dejan de devolver permisos revocados en cada despliegue.
- Timeouts en las llamadas HTTP a Telegram y dLocal que podían colgarse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 03:20:00 +00:00
Lizandro GDandClaude Sonnet 5 5607836261 feat: pantalla para administrar chats autorizados del agente de Telegram
Antes la whitelist de telegram_agent_auth solo se podía tocar con curl/Postman
contra /api/v2/agent/auth. Se agrega /app/agente/chats-autorizados con un
CRUD simple, más un botón "buscar mensajes recientes" (UpdatesRecientesDelBot,
vía getUpdates) para descubrir el chat_id de alguien que le acaba de escribir
al bot sin tener que pedírselo por otro medio. Entrada nueva en el menú lateral
del módulo Automatización IA.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 02:37:40 +00:00
Lizandro GDandClaude Sonnet 5 7b0e4b9b5c fix: convertir Markdown a HTML de Telegram antes de enviar mensajes del agente
El bot manda mensajes con parse_mode=HTML, pero la IA responde en Markdown
(##, **negrita**, listas con "-", separadores ---), que Telegram no
interpreta — salía todo literal con los símbolos. Ahora FormatearParaTelegram
convierte el Markdown a las etiquetas que Telegram sí soporta (b, code, pre,
a) justo antes de enviar, escapando cualquier < > & literal para que
sendMessage tampoco falle por HTML inválido. Se ajustó el único lugar que ya
armaba HTML a mano (/instancias) para que use el mismo formato Markdown y
pase por el mismo conversor. Incluye tests con el caso real reportado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 02:26:42 +00:00
Lizandro GDandClaude Sonnet 5 a0b2e9ae0a feat: registrar proyectos con fases, adjuntar documentos y asignar tareas por Telegram
- crear_fase_proyecto: agrega fases/etapas a un proyecto (se puede llamar
  varias veces seguidas para cargar todas las fases de un proyecto nuevo).
- adjuntar_documento_proyecto: guarda el archivo que el usuario acaba de
  enviar por Telegram como documento del proyecto (mismo mecanismo de
  adjuntos ya usado para facturas — se generalizó TelegramAttachment para
  servir a ambos casos).
- listar_usuarios + asignado_id en crear_tarea + asignar_tarea: permite
  asignar tareas a alguien del equipo por Telegram, disparando la misma
  notificación (Telegram/email/sistema) que ya dispara el dashboard.
- El prompt del sistema ahora indica cómo decidir entre adjuntar_factura y
  adjuntar_documento_proyecto según el contexto del archivo recibido.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 02:11:02 +00:00
Lizandro GDandClaude Sonnet 5 cd0ca6d232 feat: leer el contenido real del documento adjunto con Claude
Antes solo se avisaba en texto "llegó un documento" — la IA nunca veía el
archivo, por eso siempre tenía que preguntar cliente y monto. Ahora, cuando
el proveedor activo es Anthropic, se le adjunta la imagen/PDF real (base64)
al mensaje para que Claude lo lea directamente y extraiga cliente, monto y
número de factura, llamando a adjuntar_factura sin pedir confirmación salvo
que de verdad no pueda determinar cliente o monto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 02:01:12 +00:00
Lizandro GDandClaude Sonnet 5 b292d98cf6 feat: enviar factura por Telegram como documento adjunto
El bot solo procesaba mensajes de texto: un documento o foto enviado al chat
se ignoraba por completo (Telegram lo manda en message.document/photo con
caption, no en el campo text). Ahora el webhook detecta el adjunto, lo
descarga desde la API de Telegram y lo deja pendiente para el chat; el agente
usa la nueva tool adjuntar_factura para asociarlo a un cliente (preguntando
cliente/monto si el caption no los trae) y crear la Factura con el archivo ya
vinculado, igual que si se subiera desde el dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 01:37:13 +00:00
Lizandro GDandClaude Sonnet 5 8fce587304 fix: recalcular consolidado mensual automáticamente al crear/editar/borrar transacciones
CalcularYGuardarConsolidado solo se ejecutaba al visitar /contabilidad/consolidado,
así que un consolidado ya guardado quedaba desincronizado en cuanto alguien
editaba o borraba una transacción de ese mes. Ahora CreateTransaccion,
UpdateTransaccion y DeleteTransaccion recalculan el consolidado del mes
afectado (y del mes anterior si una edición cambia la fecha de mes). También
aplica a los pagos de cuentas por cobrar/pagar, que ahora crean su
transacción a través de CreateTransaccion en vez de un Create directo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 01:29:16 +00:00
Lizandro GDandClaude Sonnet 5 d2bf699b60 fix: seguridad del webhook de soporte, pagos en contabilidad y Telegram para tareas
- soporte: el webhook de correo entrante era público sin ninguna validación;
  ahora exige una API key (query ?key= o header) comparada en tiempo constante.
  Además evita tickets duplicados por reintentos del proveedor (dedup por
  Message-Id) y enhebra respuestas del mismo remitente en vez de abrir un
  ticket nuevo por cada correo.
- contabilidad: marcar una cuenta por cobrar/pagar como pagada ahora crea y
  vincula la Transaccion correspondiente (antes el dashboard de ingresos/
  egresos nunca reflejaba esos pagos). Se corrige además que actualizar una
  cuenta por cobrar borraba su transaccion_id en cada PUT.
- tareas: se activa por defecto el canal Telegram para tarea_asignada (estaba
  apagado desde el seed original) y se agrega un flujo real de vinculación de
  Telegram para el staff interno (código temporal + verificación), igual al
  que ya existía para los usuarios del portal — sin esto el chat_id de cada
  usuario había que pegarlo a mano y la notificación nunca llegaba.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 01:25:17 +00:00
Lizandro GDandClaude Sonnet 5 1127d944c7 fix: loguear errores de AutoMigrate en el arranque normal
AutoMigrate se detiene en el primer modelo que falla y no continúa con el
resto de la lista. Como esta llamada no revisaba el error, cualquier fallo
silencioso dejaba sin crear todas las tablas listadas después (incluidas
plantillas_documento, tarifas, documentos_generados, arquitecturas).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 01:06:46 +00:00
Lizandro GDandClaude Sonnet 5 c6227150a3 fix: evitar spinner de carga infinito si falla la API en plantillas/tarifas
loadData() no tenía try/catch: si la petición fallaba (deploy sin el código
nuevo, tabla inexistente, etc.) el estado "loading" nunca volvía a false y la
pantalla quedaba trabada en el overlay de carga sin ningún mensaje de error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 01:00:56 +00:00
Lizandro GD a36e2e1048 Merge remote-tracking branch 'origin/main'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

# Conflicts:
#	main.go
2026-08-03 00:47:00 +00:00
Lizandro GDandClaude Sonnet 5 516220a8a1 feat: automatización de cotizaciones, contratos, actas y cuentas de cobro con IA
Implementa las 4 fases de la especificación de automatización: módulo de
plantillas/tarifas editable por el equipo, generación de PDF (HTML+JS vía
Chrome headless) para cotizaciones/contratos/arquitecturas/cuentas de cobro,
chat propio en el dashboard reutilizando el mismo motor y tools del bot de
Telegram, y nuevas tools del agente para crear estos documentos end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 00:45:58 +00:00
Lizandro Guarnizo 03b18418eb feat: SMTP salida propio en webhook de soporte 2026-07-30 12:19:56 -05:00
Lizandro Guarnizo 333fe03de0 fix: historial por usuario (user_id en query_history) 2026-07-30 11:02:19 -05:00
Lizandro Guarnizo 1d7bfd3596 feat: multi-query/upload .sql en query runner + selector BD por rol 2026-07-30 09:19:24 -05:00
Lizandro Guarnizo 28affb5a55 feat: área de soporte con webhook de correo y asignación de tickets 2026-07-28 23:39:03 -05:00
Lizandro GD 772a4a5656 fix: editar instancia Coolify trae URL fresca del server y valida base_url no vacio 2026-07-22 13:27:02 +00:00
Lizandro GD a1d4c9da23 style: mejorar UI de tarjetas de instancias con iconos y jerarquia visual 2026-07-22 13:02:20 +00:00
Lizandro GD ed19eb3fd6 feat: soporte multi-instancia Coolify en panel web 2026-07-22 02:07:25 +00:00
Lizandro GDandClaude Opus 4.6 d9933a754b feat(agent): comandos /reset /limpiar /start borran historial desde Telegram
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-13 19:46:18 +00:00
Lizandro GDandClaude Opus 4.6 c7b71d914f perf(agent): reducir tokens ~75% guardando solo user+assistant en historial
Los tool_calls y tool_results intermedios (JSON crudo de listas) se
mantenían en BD y se reenviaban a la IA en cada turno, consumiendo
la mayoría de tokens sin aportar contexto nuevo.

Ahora solo se persisten mensajes user y la respuesta final assistant.
Los datos de herramientas viven únicamente en memoria durante el round
actual. También se reduce el límite de historial de 20 a 10 mensajes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-13 19:45:34 +00:00
Lizandro GDandClaude Opus 4.6 622a89e966 fix: restaurar tool_calls en historial al cargar conversación del agente
Cuando el agente hacía múltiples rondas de herramientas, los mensajes
del asistente con tool_calls se guardaban como JSON en Content. Al
recargar el historial, ToolCalls quedaba nil y Anthropic recibía
tool_result sin el tool_use correspondiente → error 400.

Ahora se detecta si Content es un array JSON y se deserializa de vuelta
a []agentToolCall antes de enviarlo a la API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-13 19:36:48 +00:00
Lizandro GDandClaude Sonnet 4.6 ee9a2f09d6 feat: notificaciones Telegram en webhook de Coolify
- CoolifyWebhook parsea el payload y envía mensaje formateado a Telegram
- Detecta estado ( success /  fail / 🚀 deploying / ⏹ stop / 🔄 restart)
- Ruta /webhooks/coolify/:config_id identifica de qué instancia viene
- Notifica a todos los chats autorizados del agente bot
- GetAgentTelegramConfig() en models para obtener el bot activo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 19:28:59 +00:00
Lizandro GD a8786e2aca fix: exponer es_agente_bot y telegram_config_id en respuesta de AI Config 2026-07-13 19:20:50 +00:00
Lizandro GDandClaude Sonnet 4.6 4f309ad9d5 feat: soporte nativo Anthropic en el agente (tool use format)
Agrega callAnthropicAI() que convierte mensajes y herramientas al formato
nativo de Anthropic Messages API (tool_use / tool_result), con conversión
de vuelta al formato interno OpenAI para mantener el historial unificado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 19:16:11 +00:00
Lizandro GDandClaude Sonnet 4.6 5c81939bbf feat: agente - agregar listar_facturas + campos agente en AI Config create/update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 18:31:15 +00:00
Lizandro GDandClaude Sonnet 4.6 33cfe4fdb0 feat: agente Telegram con IA + Coolify multi-instancia
- Coolify: soporte multi-instancia (CRUD de configs, ?config_id= en todos
  los endpoints, endpoints expandidos para services/databases/teams/envs)
- AiConfig: campos es_agente_bot + telegram_config_id para marcar qué
  config de IA actúa como cerebro del bot administrador
- TelegramAgentHistory + TelegramAgentAuth: historial de conversación por
  chat_id y whitelist de chats autorizados
- Agent Engine: function calling OpenAI-compatible con 25+ herramientas
  (clientes, contratos, contabilidad, proyectos, tickets, tareas,
  Coolify multi-instancia, servidores, monitores URL)
- Webhook POST /webhooks/telegram-agent/:bot_token (público, sin sesión)
- API /api/v2/agent/auth y /api/v2/agent/history para administrar el agente
- AutoMigrate: AiConfig, TelegramAgentHistory, TelegramAgentAuth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 18:29:06 +00:00
Lizandro GDandClaude Sonnet 4.6 50812749ea fix: corregir argumentos faltantes en fmt.Sprintf y usar net.JoinHostPort
- generateModelFileContent: faltaban 4 args (item Update, Delete comment/func/model)
- generateControllerFileContent: faltaban 2 args (Delete func y mensaje)
- generateRouteFileContent: faltaba 1 arg (comentario Delete)
- servidor_controller: usar net.JoinHostPort para compatibilidad con IPv6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 18:19:19 +00:00
Lizandro GDandClaude Opus 4.6 9fd0772e91 refactor: rename Hermes API to Admin API v2 under /api/v2
Integrates the external API into the existing /api group as v2 with
API key auth (ADMIN_API_KEY), replacing the separate /hermes namespace.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-13 12:17:08 +00:00
Lizandro GDandClaude Sonnet 5 f8d44f3c21 docs: instrucciones completas de Hermes API para consumo externo
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 03:05:49 +00:00
Lizandro GDandClaude Sonnet 5 13c3b4c16c feat: endpoint /hermes/spec con documentación completa de la API
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 03:01:15 +00:00
293 changed files with 36422 additions and 1070 deletions
-27
View File
@@ -1,27 +0,0 @@
#VITE_API_URL=
#VITE_WS_URL=
APP_URL=https://admin.u-site.app
DB_DRIVER=postgres
DB_HOST=t2yq39od6o6cgxaakw6wbu9v
DB_PORT=5432
DB_USER=usite
DB_PASS=DXPc2nzKWurNThEaWffGFuy47b65CWV4O9X75td2uOGj3kFLimOgLppWplYbx5Fu
DB_NAME=usite
MAIL_HOST=smtp.hostinger.com
MAIL_USERNAME=soporte@u-s.app
MAIL_PASSWORD=Nicolas2796*+
MAIL_ENCRYPTION=TLS
MAIL_FROM_ADDRESS=soporte@u-s.app
MAIL_FROM_NAME=soporte@u-s.app
MAIL_PORT=465
APP_PREFORK=false
SESSION_DATABASE=./session.db
HERMES_API_KEY=hk_live_usite_2026_S3cur3K3yH3rm3s
+9
View File
@@ -6,3 +6,12 @@ DB_PORT=5432
DB_USER=postgres
DB_PASS=postgres
DB_NAME=casbin
# ─── Rapyd (wallets) ──────────────────────────────────────────────────────────
# Antes estaban escritas en pkg/services/rapyd_service.go, o sea versionadas en
# git. Las que había ahí son de sandbox y quedaron en el historial: hay que
# rotarlas en el panel de Rapyd.
# RAPYD_BASE_URL por defecto apunta a sandbox; en producción es https://api.rapyd.net
RAPYD_ACCESS_KEY=
RAPYD_SECRET_KEY=
RAPYD_BASE_URL=https://sandboxapi.rapyd.net
+4 -2
View File
@@ -4,7 +4,8 @@ main
/tmp
/node_modules/
*.fiber.gz
#.env
.env
.env.local
.DS_Store
dump.rdb
#uploads
@@ -17,4 +18,5 @@ stats/
pnpm-lock.yaml
package-lock.json
.task-project
*.db
*.db
session.db
+482
View File
@@ -0,0 +1,482 @@
================================================================================
U-Site Admin API v2 — Instrucciones de acceso
================================================================================
Eres el asistente de gestión de U-Site SAS BIC. Tienes acceso total a la
plataforma administrativa de U-Site a través de la Admin API v2.
Tu trabajo es consultar, crear, actualizar y eliminar recursos del sistema
cuando el usuario lo necesite. Siempre usa la API para obtener datos reales
y actualizados.
================================================================================
CONEXIÓN
================================================================================
Base URL: https://admin.u-site.app/api/v2
Autenticación: Header "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n"
Formato: JSON (Content-Type: application/json)
Spec completa: GET /api/v2/spec (consulta este endpoint si necesitas refrescar
la lista completa de endpoints disponibles)
================================================================================
REGLAS GENERALES
================================================================================
- Todos los GET con listados aceptan: ?page=1&search=texto
- Las respuestas paginadas tienen: { items, total, totalPages, page }
- Los IDs son numéricos (uint)
- Las fechas van en formato: "2026-01-15" (YYYY-MM-DD)
- Para crear: POST con body JSON
- Para editar: PUT /:id con body JSON
- Para eliminar: DELETE /:id
- Los select (dropdowns) retornan arrays simples para armar opciones
================================================================================
MÓDULOS Y ENDPOINTS
================================================================================
────────────────────────────────────────────────────────────────────────────────
CLIENTES
Clientes de la empresa. Se usan en contratos, cuentas por cobrar y portal.
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/clientes Listar (?page=&limit=&search=)
GET /api/v2/clientes/select Clientes activos para dropdown
POST /api/v2/clientes Crear
Body: { nombre, empresa, email, email_cc, telefono, documento, notas }
PUT /api/v2/clientes/:id Actualizar
DELETE /api/v2/clientes/:id Eliminar
GET /api/v2/clientes/:id/documentos Documentos del cliente
POST /api/v2/clientes/:id/documentos Subir documentos (multipart)
DELETE /api/v2/clientes/:id/documentos/:docID Eliminar documento
GET /api/v2/clientes/:id/documentos/:docID/download Descargar documento
────────────────────────────────────────────────────────────────────────────────
SERVICIOS
Tipos de servicio que se ofrecen (hosting, dominio, desarrollo, etc.)
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/servicios Listar
GET /api/v2/servicios/select Servicios activos para dropdown
POST /api/v2/servicios Crear
PUT /api/v2/servicios/:id Actualizar
DELETE /api/v2/servicios/:id Eliminar
────────────────────────────────────────────────────────────────────────────────
CONTRATOS
Contratos de servicio con clientes. Ciclo de renovación y notificaciones.
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/contratos Listar (?page=&search=&estado=&servicio_id=)
POST /api/v2/contratos Crear
PUT /api/v2/contratos/:id Actualizar
DELETE /api/v2/contratos/:id Eliminar
POST /api/v2/contratos/:id/renovar Renovar contrato
POST /api/v2/contratos/:id/enviar-correo Enviar correo de notificación
GET /api/v2/contratos/:id/historial Historial del contrato
POST /api/v2/contratos/:id/verificar-pago Verificar pago Bold
POST /api/v2/contratos/:id/marcar-pagado Marcar pago manual
────────────────────────────────────────────────────────────────────────────────
CONTABILIDAD
Ingresos, egresos, cuentas por cobrar/pagar, consolidados mensuales.
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/contabilidad/dashboard Resumen del mes (?mes=7&anio=2026)
Retorna: total_ingresos, total_egresos, resultado, pendientes_cobro,
pendientes_pago, cant_transacciones, transacciones recientes
GET /api/v2/contabilidad/consolidado Calcular consolidado (?mes=&anio=)
GET /api/v2/contabilidad/consolidados Listar consolidados (?anio=)
Transacciones (ingresos y egresos):
GET /api/v2/contabilidad/transacciones Listar (?page=&search=&tipo=ingreso|egreso&mes=&anio=)
POST /api/v2/contabilidad/transacciones Crear
Body: { fecha, tipo, descripcion, valor, cuenta_id, entidad_id, forma_pago, estado, notas }
tipo: "ingreso" | "egreso"
forma_pago: "transferencia" | "efectivo" | "tarjeta" | "cheque" | "otro"
PUT /api/v2/contabilidad/transacciones/:id Actualizar
DELETE /api/v2/contabilidad/transacciones/:id Eliminar
Categorías contables (plan de cuentas):
GET /api/v2/contabilidad/cuentas Listar
GET /api/v2/contabilidad/cuentas/select Activas para dropdown
POST /api/v2/contabilidad/cuentas Crear
Body: { codigo, nombre, tipo(ingreso|egreso), color }
PUT /api/v2/contabilidad/cuentas/:id Actualizar
DELETE /api/v2/contabilidad/cuentas/:id Eliminar
Entidades contables (clientes/proveedores contables):
GET /api/v2/contabilidad/entidades Listar
GET /api/v2/contabilidad/entidades/select Activas para dropdown
POST /api/v2/contabilidad/entidades Crear
Body: { nombre, tipo(cliente|proveedor|ambos), documento, email, telefono }
PUT /api/v2/contabilidad/entidades/:id Actualizar
DELETE /api/v2/contabilidad/entidades/:id Eliminar
Cuentas por cobrar (lo que nos deben):
GET /api/v2/contabilidad/cuentas-cobro Listar (?page=&search=&estado=)
POST /api/v2/contabilidad/cuentas-cobro Crear
Body: { cliente_id, descripcion, valor, fecha, fecha_vencimiento, notas }
NOTA: cliente_id viene de /api/v2/clientes/select (NO de entidades)
PUT /api/v2/contabilidad/cuentas-cobro/:id Actualizar (estado, fecha_pago, notas)
DELETE /api/v2/contabilidad/cuentas-cobro/:id Eliminar
Cuentas por pagar (lo que debemos):
GET /api/v2/contabilidad/cuentas-pagar Listar (?page=&search=&estado=)
POST /api/v2/contabilidad/cuentas-pagar Crear
Body: { entidad_id, descripcion, valor, fecha, vencimiento, notas }
PUT /api/v2/contabilidad/cuentas-pagar/:id Actualizar
POST /api/v2/contabilidad/cuentas-pagar/:id/pagar Marcar como pagada { fecha_pago }
DELETE /api/v2/contabilidad/cuentas-pagar/:id Eliminar
────────────────────────────────────────────────────────────────────────────────
FACTURAS
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/facturas Listar
POST /api/v2/facturas Crear
PUT /api/v2/facturas/:id Actualizar
DELETE /api/v2/facturas/:id Eliminar
POST /api/v2/facturas/:id/upload-pdf Subir PDF
GET /api/v2/facturas/:id/download Descargar PDF
────────────────────────────────────────────────────────────────────────────────
PROYECTOS
Proyectos del portal de clientes. Contienen fases, avances, entregables,
tickets y documentos.
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/proyectos Listar
POST /api/v2/proyectos Crear
PUT /api/v2/proyectos/:id Actualizar
DELETE /api/v2/proyectos/:id Eliminar
Fases del proyecto:
GET /api/v2/proyectos/:id/fases Listar fases
POST /api/v2/proyectos/:id/fases Crear fase
PUT /api/v2/proyectos/:id/fases/:faseID Actualizar fase
DELETE /api/v2/proyectos/:id/fases/:faseID Eliminar fase
Avances:
GET /api/v2/proyectos/:id/avances Listar avances
POST /api/v2/proyectos/:id/avances Crear avance
PUT /api/v2/proyectos/:id/avances/:avID Actualizar
DELETE /api/v2/proyectos/:id/avances/:avID Eliminar
Entregables:
GET /api/v2/proyectos/:id/entregables Listar
POST /api/v2/proyectos/:id/entregables Subir entregable
DELETE /api/v2/proyectos/:id/entregables/:entID Eliminar
GET /api/v2/proyectos/:id/entregables/:entID/download Descargar
Tickets:
GET /api/v2/proyectos/:id/tickets Listar tickets
PUT /api/v2/proyectos/:id/tickets/:ticketID/estado Cambiar estado
POST /api/v2/proyectos/:id/tickets/:ticketID/mensaje Responder
Documentos:
GET /api/v2/proyectos/:id/documentos Listar
POST /api/v2/proyectos/:id/documentos Subir
DELETE /api/v2/proyectos/:id/documentos/:docID Eliminar
GET /api/v2/proyectos/:id/documentos/:docID/download Descargar
────────────────────────────────────────────────────────────────────────────────
TICKETS (global, todos los proyectos)
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/tickets Listar todos
PUT /api/v2/tickets/:ticketID/estado Cambiar estado
POST /api/v2/tickets/:ticketID/mensaje Responder
────────────────────────────────────────────────────────────────────────────────
TAREAS (Kanban)
Estados: pendiente, en_progreso, completada, cancelada
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/tareas Listar (?estado=&asignado_a=&search=)
GET /api/v2/tareas/usuarios Usuarios disponibles para asignar
POST /api/v2/tareas Crear
GET /api/v2/tareas/:id Detalle con comentarios
PUT /api/v2/tareas/:id Actualizar
PUT /api/v2/tareas/:id/estado Cambiar estado { estado }
DELETE /api/v2/tareas/:id Eliminar
POST /api/v2/tareas/:id/comentario Agregar comentario
────────────────────────────────────────────────────────────────────────────────
USUARIOS DEL SISTEMA
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/users Listar
GET /api/v2/users/:id Detalle
POST /api/v2/users Crear { nombre_usuario, email, password, role_id }
PUT /api/v2/users/:id Actualizar
DELETE /api/v2/users/:id Eliminar
────────────────────────────────────────────────────────────────────────────────
ROLES
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/roles Listar
POST /api/v2/roles Crear
PUT /api/v2/roles/:id Actualizar
DELETE /api/v2/roles/:id Eliminar
────────────────────────────────────────────────────────────────────────────────
MÓDULOS Y SUBMÓDULOS
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/modules Listar módulos
POST /api/v2/modules Crear
PUT /api/v2/modules/:id Actualizar
DELETE /api/v2/modules/:id Eliminar
GET /api/v2/submodules Listar submódulos
POST /api/v2/submodules Crear
PUT /api/v2/submodules/:id Actualizar
DELETE /api/v2/submodules/:id Eliminar
────────────────────────────────────────────────────────────────────────────────
PORTAL DE USUARIOS (login externo de clientes)
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/portal-usuarios Listar
GET /api/v2/portal-usuarios/:id Detalle
POST /api/v2/portal-usuarios Crear
PUT /api/v2/portal-usuarios/:id Actualizar
DELETE /api/v2/portal-usuarios/:id Eliminar
POST /api/v2/portal-usuarios/:id/acceso Agregar acceso a cliente
DELETE /api/v2/portal-usuarios/:id/acceso/:clienteID Quitar acceso
POST /api/v2/portal-usuarios/:id/send-credentials Enviar credenciales por correo
────────────────────────────────────────────────────────────────────────────────
SERVIDORES
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/servidores Listar
POST /api/v2/servidores Crear
PUT /api/v2/servidores/:id Actualizar
DELETE /api/v2/servidores/:id Eliminar
GET /api/v2/servidores/:id/dashboard Dashboard del servidor
GET /api/v2/servidores/:id/metricas-history Historial de métricas
GET /api/v2/prov-servidores Proveedores de servidor (CRUD)
GET /api/v2/tipos-servidor Tipos de servidor (CRUD)
GET /api/v2/tipos-db Tipos de DB (CRUD)
GET /api/v2/conexiones-ssh Conexiones SSH (CRUD)
GET /api/v2/conexiones-db Conexiones DB (CRUD)
────────────────────────────────────────────────────────────────────────────────
MONITOR DE URLs
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/url-monitors Listar
POST /api/v2/url-monitors Crear
PUT /api/v2/url-monitors/:id Actualizar
DELETE /api/v2/url-monitors/:id Eliminar
POST /api/v2/url-monitors/:id/check Chequear ahora
GET /api/v2/url-monitors/:id/logs Logs del monitor
────────────────────────────────────────────────────────────────────────────────
NOTIFICACIONES
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/plantillas-correo Plantillas de correo (CRUD)
GET /api/v2/reglas-notificacion Reglas de notificación (CRUD)
GET /api/v2/historial-notificaciones Historial de envíos
POST /api/v2/historial-notificaciones/:id/reenviar Reenviar
GET /api/v2/smtp-config Config SMTP
POST /api/v2/smtp-config Guardar SMTP
GET /api/v2/notif-config Config notificaciones por evento
POST /api/v2/notif-config Guardar
GET /api/v2/servidor-alerta-config Umbrales de alerta
POST /api/v2/servidor-alerta-config Guardar umbrales
────────────────────────────────────────────────────────────────────────────────
TELEGRAM
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/telegram Listar configs
POST /api/v2/telegram Crear config
PUT /api/v2/telegram/:id Actualizar
DELETE /api/v2/telegram/:id Eliminar
POST /api/v2/telegram/:id/test Enviar test
POST /api/v2/telegram/send Enviar notificación { config_id, message }
GET /api/v2/telegram/logs Logs de envíos
────────────────────────────────────────────────────────────────────────────────
PRODUCTOS SaaS
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/saas Listar productos
POST /api/v2/saas Crear
PUT /api/v2/saas/:id Actualizar
DELETE /api/v2/saas/:id Eliminar
GET /api/v2/saas/:id/health Health check
GET /api/v2/saas-api Integraciones SaaS (CRUD)
GET /api/v2/saas-api/logs Logs de despacho
────────────────────────────────────────────────────────────────────────────────
DOCUMENTACIÓN
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/doc/categorias Categorías (CRUD)
GET /api/v2/doc/paginas Páginas (CRUD)
GET /api/v2/doc/paginas/:id Detalle de página
────────────────────────────────────────────────────────────────────────────────
CONFIGURACIONES DE IA
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/ai-config Listar
GET /api/v2/ai-config/select Activas para select
POST /api/v2/ai-config Crear
PUT /api/v2/ai-config/:id Actualizar
DELETE /api/v2/ai-config/:id Eliminar
GET /api/v2/ai-config/:id/test Probar
────────────────────────────────────────────────────────────────────────────────
OSS API (almacenamiento S3/MinIO/Alibaba)
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/oss-api Listar configs
GET /api/v2/oss-api/active Configs activas
POST /api/v2/oss-api Crear
PUT /api/v2/oss-api/:id Actualizar
DELETE /api/v2/oss-api/:id Eliminar
GET /api/v2/oss-api/browser Explorar archivos (?prefix=&config_id=)
GET /api/v2/oss-api/browser/url URL firmada de descarga
POST /api/v2/oss-api/browser/upload Subir archivo
DELETE /api/v2/oss-api/browser/object Eliminar archivo
────────────────────────────────────────────────────────────────────────────────
USITE SHIELD
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/shield Config Shield
POST /api/v2/shield Guardar config
GET /api/v2/shield/health Health check
GET /api/v2/shield/logs Logs
GET /api/v2/shield/logs/stats Estadísticas
GET /api/v2/shield/review-requests Solicitudes de revisión
PUT /api/v2/shield/review-requests/:id/approve Aprobar
PUT /api/v2/shield/review-requests/:id/reject Rechazar
GET /api/v2/shield/whitelist Whitelist (CRUD)
GET /api/v2/shield/blacklist Blacklist (CRUD)
────────────────────────────────────────────────────────────────────────────────
WEBSMS (LabsMobile)
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/websms/config Ver config
POST /api/v2/websms/save Guardar config
POST /api/v2/websms/test Enviar SMS de prueba
GET /api/v2/websms/logs Logs
────────────────────────────────────────────────────────────────────────────────
PASARELAS DE PAGO
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/pasarelas/bold/config Config Bold
POST /api/v2/pasarelas/bold/save Guardar Bold
GET /api/v2/pasarelas/bold/logs Logs webhooks Bold
GET /api/v2/pasarelas/dlocal/config Config dLocal
POST /api/v2/pasarelas/dlocal/save Guardar dLocal
GET /api/v2/pasarelas/dlocal/logs Logs pagos dLocal
GET /api/v2/pasarelas/paypal/config Config PayPal
POST /api/v2/pasarelas/paypal/save Guardar PayPal
────────────────────────────────────────────────────────────────────────────────
QUERY RUNNER (ejecutar SQL)
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/query-runner/connections Conexiones disponibles
GET /api/v2/query-runner/databases Databases (?connection_id=)
GET /api/v2/query-runner/tables Tablas (?connection_id=&database=)
GET /api/v2/query-runner/test Probar conexión (?connection_id=)
POST /api/v2/query-runner/run Ejecutar query { connection_id, database, query }
GET /api/v2/query-runner/history Historial
GET /api/v2/query-runner/columns Columnas (?connection_id=&database=&table=)
────────────────────────────────────────────────────────────────────────────────
INTEGRACIONES EXTERNAS
────────────────────────────────────────────────────────────────────────────────
Hostinger:
GET /api/v2/hostinger/vps VPS
GET /api/v2/hostinger/domains Dominios
GET /api/v2/hostinger/dns/:domain DNS de un dominio
GET /api/v2/hostinger/orders Órdenes
GET /api/v2/hostinger/hosting Hosting
Cloudflare:
GET /api/v2/cloudflare/zones Zonas
GET /api/v2/cloudflare/zones/:zone_id/dns DNS de una zona
POST /api/v2/cloudflare/zones/:zone_id/dns Crear registro DNS
PUT /api/v2/cloudflare/zones/:zone_id/dns/:id Actualizar registro DNS
DELETE /api/v2/cloudflare/zones/:zone_id/dns/:id Eliminar registro DNS
Coolify:
GET /api/v2/coolify/apps Aplicaciones
GET /api/v2/coolify/apps/:uuid Detalle app
GET /api/v2/coolify/apps/:uuid/start Iniciar
GET /api/v2/coolify/apps/:uuid/stop Detener
GET /api/v2/coolify/apps/:uuid/restart Reiniciar
POST /api/v2/coolify/apps/:uuid/deploy Desplegar
GET /api/v2/coolify/servers Servidores Coolify
GET /api/v2/coolify/servers/:uuid Detalle servidor
GET /api/v2/coolify/servers/:uuid/resources Recursos
GET /api/v2/coolify/services Servicios
GET /api/v2/coolify/databases Databases
GET /api/v2/coolify/projects Proyectos
GET /api/v2/coolify/deployments Deployments
VCard API:
GET /api/v2/vcard-api/config Config
GET /api/v2/vcard-api/usuarios Usuarios VCard
GET /api/v2/vcard-api/usuarios/:id Detalle
GET /api/v2/vcard-api/vcards VCards
GET /api/v2/vcard-api/planes Planes
GET /api/v2/vcard-api/pagos Pagos
GET /api/v2/vcard-api/miniwebs Miniwebs
Partner:
GET /api/v2/partner-recursos Recursos partner (CRUD)
GET /api/v2/partner-comunicados Comunicados partner (CRUD)
================================================================================
EJEMPLOS DE USO
================================================================================
# Listar clientes
curl -H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
"https://admin.u-site.app/api/v2/clientes?page=1&search=docuxer"
# Crear cliente
curl -X POST \
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
-H "Content-Type: application/json" \
-d '{"nombre":"Juan Pérez","empresa":"ABC SAS","email":"juan@abc.com"}' \
https://admin.u-site.app/api/v2/clientes
# Dashboard contabilidad del mes actual
curl -H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
"https://admin.u-site.app/api/v2/contabilidad/dashboard?mes=7&anio=2026"
# Crear cuenta por cobrar
curl -X POST \
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
-H "Content-Type: application/json" \
-d '{"cliente_id":1,"descripcion":"Factura #100","valor":5000000,"fecha":"2026-07-13","fecha_vencimiento":"2026-08-13"}' \
https://admin.u-site.app/api/v2/contabilidad/cuentas-cobro
# Ejecutar query SQL
curl -X POST \
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
-H "Content-Type: application/json" \
-d '{"connection_id":1,"database":"usite","query":"SELECT * FROM clientes LIMIT 10"}' \
https://admin.u-site.app/api/v2/query-runner/run
# Enviar notificación por Telegram
curl -X POST \
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
-H "Content-Type: application/json" \
-d '{"config_id":1,"message":"Alerta: servidor caído"}' \
https://admin.u-site.app/api/v2/telegram/send
# Desplegar app en Coolify
curl -X POST \
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
https://admin.u-site.app/api/v2/coolify/apps/UUID-DE-LA-APP/deploy
# Consultar spec completa (JSON) para actualizaciones
curl -H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
https://admin.u-site.app/api/v2/spec
================================================================================
NOTAS IMPORTANTES
================================================================================
- Siempre consulta /api/v2/spec para ver si hay endpoints nuevos.
- Las cuentas por cobrar usan cliente_id (de /api/v2/clientes/select).
- Las cuentas por pagar usan entidad_id (de /api/v2/contabilidad/entidades/select).
- Los valores monetarios son en COP (pesos colombianos), sin decimales normalmente.
- Si un endpoint retorna 401, verifica el API key.
- Si un endpoint retorna 503, el ADMIN_API_KEY no está configurado en el servidor.
================================================================================
+5 -3
View File
@@ -3,7 +3,7 @@
# Los assets del frontend (public/) se compilan localmente
# antes del push con: npm run production
# ──────────────────────────────────────────
FROM golang:1.25-alpine AS builder
FROM golang:1.26-alpine AS builder
WORKDIR /app
# Instalar dependencias del sistema necesarias para CGO (argon2, etc.)
@@ -29,8 +29,10 @@ RUN cd agent && go mod download && \
FROM alpine:3.20
WORKDIR /app
# Certificados TLS y timezone
RUN apk add --no-cache ca-certificates tzdata
# Certificados TLS, timezone y Chromium headless (motor de generación de PDF
# para cotizaciones, contratos, actas y cuentas de cobro — ver pkg/services/pdf_service.go)
RUN apk add --no-cache ca-certificates tzdata chromium
ENV CHROME_EXEC_PATH=/usr/bin/chromium-browser
# Copiar binario compilado
COPY --from=builder /app/apiv2 .
+81
View File
@@ -0,0 +1,81 @@
# Variables de entorno requeridas
> **Leer antes del siguiente despliegue.** El arranque falla a propósito si los
> secretos JWT siguen teniendo el valor que estuvo publicado en el repositorio.
## 1. Secretos que hay que definir sí o sí
`.env` y `config.yml` estuvieron versionados con credenciales reales, así que
**todo lo que aparecía ahí debe considerarse comprometido y rotarse**, no solo
sacarse del repositorio: el historial de git lo sigue conteniendo.
Genera cada valor con `openssl rand -hex 32` y cárgalos como variables de
entorno del contenedor (en Coolify: *Environment Variables*):
| Variable | Para qué sirve | Si no se define |
|---|---|---|
| `APP_JWT_SECRET` | Firma las cookies de sesión del panel | **El arranque se detiene** |
| `API_JWT_SECRET` | Firma los tokens de la API v1 | **El arranque se detiene** |
| `APP_KEY` | Cifra las contraseñas de SMTP e integraciones | Arranca, pero avisa en los logs |
| `ADMIN_API_KEY` | Única llave de toda la API `/api/v2` | La API v2 responde 503 |
Al rotar `APP_JWT_SECRET` se cierran las sesiones abiertas: hay que volver a
iniciar sesión, nada más.
### Cuidado con `APP_KEY`
`APP_KEY` cifra las contraseñas guardadas en la base de datos. Si la cambias,
**las contraseñas cifradas con la clave anterior dejan de poder descifrarse** y
hay que volver a guardarlas desde el panel:
- Configuración SMTP (`/app/smtp-config`)
- Credenciales de las pasarelas de pago (`/app/pasarelas`)
- Cualquier otra integración con contraseña
Por eso el sistema solo advierte en vez de detenerse: para que elijas el momento.
## 2. Otras credenciales a rotar
Estaban en el `.env` versionado:
- Contraseña de PostgreSQL
- Contraseña del correo saliente
- Cualquier token de integración (Coolify, Cloudflare, Hostinger, Telegram…)
## 3. Webhooks de pago
Las notificaciones ahora **exigen firma válida**. Verifica en cada proveedor que
el secreto de firma coincida con el configurado en `/app/pasarelas`:
| Pasarela | URL del webhook | Requiere |
|---|---|---|
| Bold | `https://TU-DOMINIO/webhooks/bold` | Secret de firma en la config |
| dLocal | `https://TU-DOMINIO/webhooks/dlocal` | Secret de firma en la config |
| PayPal | `https://TU-DOMINIO/webhooks/paypal` | **Webhook ID** en la config |
Para PayPal hay que crear el webhook en el panel de PayPal suscrito a los
eventos `CHECKOUT.ORDER.APPROVED` y `PAYMENT.CAPTURE.COMPLETED`, y pegar el
Webhook ID que devuelve en `/app/pasarelas`. Sin ese ID no se pueden verificar
las notificaciones y se rechazan.
> PayPal no opera en pesos colombianos. Los contratos que se cobren por PayPal
> deben tener la moneda en USD (u otra soportada), o PayPal rechazará la orden.
## 4. Datos contables de ejemplo
`SeedBalanceData` (27 transacciones de 2026 escritas en el código) ya **no**
corre en cada arranque: si el contador editaba o borraba una, el siguiente
reinicio la recreaba y el balance quedaba duplicado.
Para cargarla puntualmente:
```bash
SEED_BALANCE=1 ./apiv2 -config config.yml
```
## 5. Archivos subidos
`/uploads` ya no es público. Antes, sabiendo la ruta se podía descargar el RUT de
un cliente o una factura sin iniciar sesión. Ahora requiere sesión en el panel;
los clientes del portal siguen descargando sus documentos por los endpoints de
siempre, que además validan que el archivo les pertenezca.
+278
View File
@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>uMind — Casos de uso</title>
<style>
:root {
--verde: #8eb02f;
--verde-oscuro: #5a7a1e;
--texto: #2b2b2b;
--gris: #6b7280;
--borde: #e5e7eb;
--fondo-suave: #f7f8f5;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--texto);
line-height: 1.6;
max-width: 860px;
margin: 0 auto;
padding: 40px 24px 80px;
background: #fff;
}
header {
border-bottom: 3px solid var(--verde);
padding-bottom: 20px;
margin-bottom: 32px;
}
header .badge {
display: inline-block;
background: var(--verde);
color: #fff;
font-size: 12px;
font-weight: 700;
letter-spacing: .04em;
padding: 4px 10px;
border-radius: 999px;
margin-bottom: 12px;
}
h1 {
font-size: 32px;
margin: 0 0 6px;
color: #1f2937;
}
header p.subtitulo {
color: var(--gris);
font-size: 14px;
margin: 0;
}
h2 {
font-size: 20px;
color: var(--verde-oscuro);
margin-top: 44px;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--borde);
}
h3 {
font-size: 15px;
margin: 20px 0 6px;
color: #1f2937;
}
p { margin: 10px 0; }
code {
background: var(--fondo-suave);
border: 1px solid var(--borde);
border-radius: 4px;
padding: 2px 6px;
font-size: 13px;
font-family: "SF Mono", Menlo, Consolas, monospace;
color: #b0530f;
}
pre {
background: #1f2937;
color: #d1fae5;
padding: 16px 18px;
border-radius: 8px;
overflow-x: auto;
font-size: 13px;
font-family: "SF Mono", Menlo, Consolas, monospace;
}
pre code { background: none; border: none; color: inherit; padding: 0; }
ul, ol { padding-left: 22px; }
li { margin: 6px 0; }
.caso {
background: var(--fondo-suave);
border: 1px solid var(--borde);
border-left: 4px solid var(--verde);
border-radius: 8px;
padding: 14px 18px;
margin: 14px 0;
}
.caso h3 { margin-top: 0; color: var(--verde-oscuro); }
.caso p { margin: 4px 0 0; font-size: 14px; color: #374151; }
.grid-casos {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
@media (max-width: 640px) {
.grid-casos { grid-template-columns: 1fr; }
body { padding: 24px 16px 60px; }
h1 { font-size: 26px; }
}
.callout {
border-radius: 8px;
padding: 16px 18px;
margin: 18px 0;
font-size: 14px;
}
.callout.bien {
background: #eef6e3;
border: 1px solid #c9e0a3;
}
.callout.limite {
background: #fdf3e7;
border: 1px solid #f0d3a3;
}
.callout strong { display: block; margin-bottom: 6px; }
.lista-check { list-style: none; padding-left: 0; }
.lista-check li { padding-left: 26px; position: relative; }
.lista-check li::before {
content: "✓";
color: var(--verde-oscuro);
font-weight: 700;
position: absolute;
left: 0;
}
.lista-cruz li::before {
content: "✕";
color: #c05621;
}
footer {
margin-top: 60px;
padding-top: 20px;
border-top: 1px solid var(--borde);
font-size: 12px;
color: var(--gris);
text-align: center;
}
@media print {
body { padding: 0; max-width: 100%; }
.caso { break-inside: avoid; }
}
</style>
</head>
<body>
<header>
<span class="badge">DOCUMENTO COMERCIAL</span>
<h1>uMind — Casos de uso</h1>
<p class="subtitulo">Asistente de chat con IA, embebible en cualquier sitio web · Agosto 2026</p>
</header>
<h2>Qué es</h2>
<p>
uMind es un asistente de chat con inteligencia artificial que se instala en cualquier sitio web con una sola línea de código
— igual que Google Analytics o un widget de chat tradicional. A diferencia de un chatbot de respuestas fijas, uMind
<strong>aprende automáticamente el contenido del sitio del cliente</strong> (páginas, servicios, precios, políticas) y
responde preguntas de los visitantes las 24 horas, sin que nadie tenga que programar ni un solo flujo de conversación.
</p>
<p><strong>La instalación es literalmente esto:</strong></p>
<pre><code>&lt;script src="https://admin.u-site.app/widget/umind.js" data-site="SITE_KEY" defer&gt;&lt;/script&gt;</code></pre>
<h2>Cómo funciona (explicación simple para el cliente)</h2>
<ol>
<li>Le pedimos la URL de su sitio.</li>
<li>uMind lo recorre automáticamente y "aprende" el contenido — servicios, productos, preguntas frecuentes, políticas, lo que ya está publicado.</li>
<li>Le entregamos el código de instalación (una línea).</li>
<li>Desde ese momento, cualquier visitante puede hacerle preguntas al asistente por chat, y responde con base en el contenido real del sitio.</li>
<li>Si no sabe algo, no inventa: lo dice honestamente y puede ofrecer que un humano del equipo lo contacte.</li>
</ol>
<h2>Por qué es diferente a un chatbot normal</h2>
<ul>
<li><strong>No hay que escribir guiones de conversación.</strong> El conocimiento sale directo del sitio; si el cliente actualiza su página de precios, basta con volver a "ingestar" el sitio.</li>
<li><strong>No inventa respuestas.</strong> Está instruido para decir "no tengo esa información" en vez de alucinar — algo que preocupa a cualquier negocio serio antes de exponer un bot a sus clientes.</li>
<li><strong>Se instala en minutos, no en semanas.</strong></li>
<li><strong>Funciona en cualquier sitio</strong>, sin importar en qué tecnología esté construido (WordPress, Webflow, HTML plano, una app hecha por U-Site, etc.).</li>
</ul>
<h2>Casos de uso generales</h2>
<div class="caso">
<h3>1. Atención de preguntas frecuentes 24/7</h3>
<p>Horarios, ubicación, formas de pago, políticas de garantía/devolución, sin que el equipo tenga que responder lo mismo veinte veces al día.</p>
</div>
<div class="caso">
<h3>2. Primer filtro de soporte</h3>
<p>Responde lo que puede resolver solo (el 60-80% de las preguntas repetitivas en la mayoría de negocios) y libera al equipo humano para casos que realmente lo requieren.</p>
</div>
<div class="caso">
<h3>3. Guía de productos/servicios</h3>
<p>Un visitante nuevo pregunta "¿qué planes tienen?" o "¿hacen envíos a X ciudad?" y obtiene respuesta inmediata, sin buscar en menús o PDFs.</p>
</div>
<div class="caso">
<h3>4. Reducción de abandono</h3>
<p>Un visitante indeciso que en otro momento se iría del sitio sin comprar/contactar, ahora tiene a quién preguntarle ahí mismo.</p>
</div>
<div class="caso">
<h3>5. Captura de interesados fuera de horario</h3>
<p>El negocio "cierra" pero el asistente sigue respondiendo y puede recoger el interés del visitante para que el equipo humano lo contacte al día siguiente.</p>
</div>
<h2>Casos de uso por tipo de negocio</h2>
<div class="grid-casos">
<div class="caso">
<h3>🍽️ Restaurantes / gastronomía</h3>
<p>Menú, horarios, zona para eventos, reservas, opciones para dietas especiales, ubicación y domicilios.</p>
</div>
<div class="caso">
<h3>🩺 Clínicas / consultorios / salud</h3>
<p>Servicios, especialidades, si atienden una EPS/seguro, cómo agendar cita, requisitos previos. Solo información administrativa ya publicada — sin diagnósticos ni consejos médicos.</p>
</div>
<div class="caso">
<h3>🏠 Inmobiliarias</h3>
<p>Propiedades disponibles por zona/precio, proceso para agendar una visita, requisitos para arrendar/comprar, zonas donde operan.</p>
</div>
<div class="caso">
<h3>🏨 Hoteles / alojamiento</h3>
<p>Disponibilidad general, servicios incluidos, políticas de cancelación, cómo llegar, qué hay cerca.</p>
</div>
<div class="caso">
<h3>🛒 E-commerce / tiendas online</h3>
<p>Políticas de envío y devolución, tallas/variantes, medios de pago, seguimiento de pedido (según lo publicado en el sitio).</p>
</div>
<div class="caso">
<h3>⚖️ Servicios profesionales</h3>
<p>Abogados, contadores, agencias, consultoras: qué servicios prestan, con quién han trabajado, proceso de contratación, tarifas publicadas.</p>
</div>
<div class="caso">
<h3>💻 SaaS / productos digitales</h3>
<p>Incluye los propios productos de U-Site (uLink, uVot, uBox, Habla, UIA): funcionalidades, planes, integraciones, documentación pública — reduce tickets de soporte nivel 1.</p>
</div>
<div class="caso">
<h3>🎓 Educación</h3>
<p>Colegios, institutos, cursos: programas ofrecidos, proceso de admisión/inscripción, costos, fechas importantes, requisitos.</p>
</div>
</div>
<h2>Lo que uMind todavía no hace</h2>
<div class="callout limite">
<strong>Para no prometer de más al vender esto:</strong>
<ul class="lista-check lista-cruz">
<li>No atiende por WhatsApp ni por voz/teléfono todavía — hoy es chat web únicamente. Está en el roadmap.</li>
<li>No ejecuta acciones transaccionales (agendar citas en un calendario real, procesar pagos, crear pedidos) — hoy solo informa con base en el contenido del sitio.</li>
<li>No lee contenido que no esté publicado en el sitio (PDFs privados, Excels internos, etc.) a menos que se cargue aparte.</li>
<li>No reemplaza a un humano en casos sensibles — está instruido para escalar, no para improvisar, en temas delicados (salud, legal, reclamos).</li>
</ul>
</div>
<h2>Argumentos de venta</h2>
<div class="callout bien">
<ul class="lista-check">
<li>Instalación en minutos, sin proyecto largo de desarrollo.</li>
<li>Cero mantenimiento de guiones: si el sitio cambia, se re-entrena con un clic.</li>
<li>Disponible 24/7, sin turnos ni vacaciones.</li>
<li>Reduce carga operativa del equipo de atención al cliente en preguntas repetitivas.</li>
<li>Mejora la percepción de marca: un sitio que responde al instante se siente más profesional.</li>
<li>Escalable por cliente: cada sitio tiene su propio asistente, entrenado solo con su información — nunca se mezcla el conocimiento de un cliente con el de otro.</li>
</ul>
</div>
<h2>Modelo de negocio sugerido</h2>
<p>
<em>(a definir con el equipo comercial)</em> — Un cobro mensual fijo por sitio (incluye hosting del widget y actualizaciones),
con la opción de agregar valor a futuro con paquetes de WhatsApp/voz cuando esas fases del roadmap estén listas. Los costos
operativos por conversación son bajos (fracciones de centavo de dólar por chat de texto), así que el margen sobre una
suscripción mensual es sano incluso en un plan de entrada económico.
</p>
<footer>
U-Site S.A.S BIC · uMind · Documento interno para uso comercial
</footer>
</body>
</html>
+243
View File
@@ -0,0 +1,243 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>uMind — Guía de onboarding de cliente</title>
<style>
:root {
--verde: #8eb02f;
--verde-oscuro: #5a7a1e;
--texto: #2b2b2b;
--gris: #6b7280;
--borde: #e5e7eb;
--fondo-suave: #f7f8f5;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--texto);
line-height: 1.6;
max-width: 860px;
margin: 0 auto;
padding: 40px 24px 80px;
background: #fff;
}
header {
border-bottom: 3px solid var(--verde);
padding-bottom: 20px;
margin-bottom: 32px;
}
header .badge {
display: inline-block;
background: var(--verde);
color: #fff;
font-size: 12px;
font-weight: 700;
letter-spacing: .04em;
padding: 4px 10px;
border-radius: 999px;
margin-bottom: 12px;
}
h1 { font-size: 32px; margin: 0 0 6px; color: #1f2937; }
header p.subtitulo { color: var(--gris); font-size: 14px; margin: 0; }
h2 {
font-size: 20px;
color: var(--verde-oscuro);
margin-top: 44px;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--borde);
}
h3 { font-size: 15px; margin: 20px 0 6px; color: #1f2937; }
p { margin: 10px 0; }
code {
background: var(--fondo-suave);
border: 1px solid var(--borde);
border-radius: 4px;
padding: 2px 6px;
font-size: 13px;
font-family: "SF Mono", Menlo, Consolas, monospace;
color: #b0530f;
}
pre {
background: #1f2937;
color: #d1fae5;
padding: 16px 18px;
border-radius: 8px;
overflow-x: auto;
font-size: 13px;
font-family: "SF Mono", Menlo, Consolas, monospace;
}
pre code { background: none; border: none; color: inherit; padding: 0; }
ul, ol { padding-left: 22px; }
li { margin: 6px 0; }
.paso {
display: flex;
gap: 14px;
margin: 14px 0;
align-items: flex-start;
}
.paso .num {
flex: 0 0 28px;
height: 28px;
border-radius: 50%;
background: var(--verde);
color: #fff;
font-weight: 700;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
}
.paso .contenido { flex: 1; padding-top: 2px; }
.paso .contenido strong { display: block; margin-bottom: 2px; }
.paso .contenido p { margin: 2px 0 0; font-size: 14px; color: #374151; }
.callout {
border-radius: 8px;
padding: 16px 18px;
margin: 18px 0;
font-size: 14px;
}
.callout.info { background: #eef6e3; border: 1px solid #c9e0a3; }
.callout.alerta { background: #fdf3e7; border: 1px solid #f0d3a3; }
.callout strong { display: block; margin-bottom: 6px; }
.lista-check { list-style: none; padding-left: 0; }
.lista-check li { padding-left: 26px; position: relative; }
.lista-check li::before {
content: "☐";
color: var(--verde-oscuro);
font-weight: 700;
position: absolute;
left: 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 14px 0;
font-size: 13px;
}
th, td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--borde);
}
th { color: var(--gris); font-weight: 600; text-transform: uppercase; font-size: 11px; }
footer {
margin-top: 60px;
padding-top: 20px;
border-top: 1px solid var(--borde);
font-size: 12px;
color: var(--gris);
text-align: center;
}
@media print {
body { padding: 0; max-width: 100%; }
}
</style>
</head>
<body>
<header>
<span class="badge">GUÍA INTERNA</span>
<h1>uMind — Guía de onboarding de cliente</h1>
<p class="subtitulo">Qué configurar y qué pasos seguir para llevar uMind a un cliente nuevo · Agosto 2026</p>
</header>
<h2>1. Configuración única del sistema</h2>
<p>Se hace <strong>una sola vez</strong>, no por cada cliente. Si ya está hecha, sáltate esta sección.</p>
<div class="paso">
<div class="num">1</div>
<div class="contenido">
<strong>Config de embeddings (OpenAI)</strong>
<p>En <code>/app/ai-config</code>, crea una config con provider <strong>OpenAI</strong> y activa el módulo <strong>"uMind — embeddings"</strong>. Necesita su propia API key de OpenAI. La usan <em>todos</em> los clientes para generar los vectores de búsqueda de su base de conocimiento.</p>
</div>
</div>
<div class="paso">
<div class="num">2</div>
<div class="contenido">
<strong>Config de chat (Anthropic recomendado)</strong>
<p>Al menos una config activa de chat. Se asigna a cada tenant al crearlo — puedes reutilizar la misma para todos los clientes, o crear una por cliente si más adelante quieres separar costo/uso por cliente.</p>
</div>
</div>
<h2>2. Información a pedirle al cliente antes de empezar</h2>
<table>
<tr><th>Dato</th><th>Por qué</th></tr>
<tr><td>URL exacta de su sitio</td><td>Es lo que se va a "leer" (crawlear) para entrenar el asistente.</td></tr>
<tr><td>Todos los dominios donde corre el sitio</td><td><strong>Crítico:</strong> con y sin <code>www</code>, y staging si aplica. Si falta uno, el widget se bloquea ahí por seguridad.</td></tr>
<tr><td>Tono/personalidad deseado</td><td>Opcional — formal, cercano, con emojis, etc. Si no lo dan, se usa un tono neutro por defecto.</td></tr>
<tr><td>Mensaje de bienvenida</td><td>Opcional — el saludo que ve el visitante al abrir el chat.</td></tr>
<tr><td>Quién instala el script</td><td>Su desarrollador, o alguien de U-Site si administras el sitio.</td></tr>
</table>
<h2>3. Proceso paso a paso en <code>/app/umind</code></h2>
<div class="paso">
<div class="num">1</div>
<div class="contenido">
<strong>Crear el tenant</strong>
<p>Nombre, dominios permitidos, config de IA de chat, tono, mensaje de bienvenida.</p>
</div>
</div>
<div class="paso">
<div class="num">2</div>
<div class="contenido">
<strong>Ingestar el sitio</strong>
<p>Entra al tenant → pestaña "Base de conocimiento" → pega la URL del sitio → clic en Ingestar. Espera a que el estado pase de "procesando" a "listo" (puede tardar varios minutos según el tamaño del sitio).</p>
</div>
</div>
<div class="paso">
<div class="num">3</div>
<div class="contenido">
<strong>Copiar el código de instalación</strong>
<p>Desde la ficha del tenant, copia el snippet:</p>
<pre><code>&lt;script src="https://admin.u-site.app/widget/umind.js" data-site="SITE_KEY" defer&gt;&lt;/script&gt;</code></pre>
</div>
</div>
<div class="paso">
<div class="num">4</div>
<div class="contenido">
<strong>Entregarlo al cliente</strong>
<p>Que lo peguen justo antes de <code>&lt;/body&gt;</code> en su sitio (o lo pega quien administre el sitio si es un proyecto de U-Site).</p>
</div>
</div>
<div class="paso">
<div class="num">5</div>
<div class="contenido">
<strong>Probar en el sitio real</strong>
<p>Entra al sitio del cliente y hazle un par de preguntas al widget para validar que responde bien antes de darlo por "en producción".</p>
</div>
</div>
<h2>4. Antes de vender a un cliente puntual — revisa esto</h2>
<div class="callout alerta">
<strong>Limitaciones a tener en cuenta:</strong>
<ul class="lista-check">
<li><strong>Sitios 100% React/Vue/Angular sin renderizado en servidor (SPA):</strong> el crawler solo lee el HTML que devuelve el servidor, no ejecuta JavaScript. Si el contenido del sitio se carga todo por JS, la ingesta puede salir casi vacía — revisa el sitio (o pide que lo validen) antes de prometer el servicio.</li>
<li><strong>No hay carga de PDF/Word todavía:</strong> solo se ingesta desde una URL pública. Si el cliente quiere que el asistente conozca un PDF de precios que no está publicado como página web, eso no está soportado hoy.</li>
<li><strong>Sitios muy grandes:</strong> ajustar el número máximo de páginas al ingestar (por defecto 30) si el sitio tiene cientos de páginas.</li>
<li><strong>Sin panel de costos por cliente todavía:</strong> si se factura por consumo real en vez de tarifa plana, hoy no hay forma de ver cuánto gastó cada tenant en la plataforma.</li>
</ul>
</div>
<h2>Checklist rápido</h2>
<div class="callout info">
<ul class="lista-check">
<li>Config global de embeddings (OpenAI) creada — una sola vez</li>
<li>Config global de chat (Anthropic) creada — una sola vez</li>
<li>URL y todos los dominios del cliente confirmados</li>
<li>Tenant creado en /app/umind</li>
<li>Sitio ingestado y en estado "listo"</li>
<li>Snippet copiado y entregado</li>
<li>Widget probado en el sitio real</li>
</ul>
</div>
<footer>
U-Site S.A.S BIC · uMind · Guía interna de onboarding
</footer>
</body>
</html>
+8
View File
@@ -51,6 +51,14 @@ token:
app_jwt_secret: SECRET_APP
api_jwt_secret: SECRET_API
expires_in: 31536000
# Conexiones OAuth de uMind (Gmail/Outlook) — vacío hasta crear las apps en
# Google Cloud Console / Azure. Se completan por variable de entorno
# (GOOGLE_OAUTH_CLIENT_ID, etc.), no acá.
oauth:
google_client_id: ""
google_client_secret: ""
ms_client_id: ""
ms_client_secret: ""
jwt:
app:
secret: SECRET_APP
+8
View File
@@ -55,6 +55,14 @@ token:
app_jwt_secret: SECRET_APP
api_jwt_secret: SECRET_API
expires_in: 31536000
# Conexiones OAuth de uMind (Gmail/Outlook) — vacío hasta crear las apps en
# Google Cloud Console / Azure. Se completan por variable de entorno
# (GOOGLE_OAUTH_CLIENT_ID, etc.), no acá.
oauth:
google_client_id: ""
google_client_secret: ""
ms_client_id: ""
ms_client_secret: ""
jwt:
app:
secret: SECRET_APP
+4
View File
@@ -28,6 +28,7 @@ type AppConfig struct {
Server ServerConfig `yaml:"server"`
Log LogConfig `yaml:"log"`
Token Token `yaml:"token"`
OAuth OAuthConfig `yaml:"oauth"`
Profiler ProfilerConfig `yaml:"profiler"`
Flash *flash.Flash
ConfigFile string
@@ -40,6 +41,7 @@ func (cfg *AppConfig) Setup() {
fmt.Println(err)
os.Exit(2)
}
cfg.VerificarSecretos()
cfg.Server.LoadPath()
cfg.View.Load(cfg.Server.Path)
cfg.Mail.View = &cfg.View
@@ -94,6 +96,8 @@ func (cfg *AppConfig) LoadComponents() {
func (cfg *AppConfig) LoadStatic() {
cfg.Server.Static("/websocket", "./resources/views/websocket.html")
// El guard se registra antes que el estático para que corra primero.
cfg.Server.Use("/uploads", uploadsProtegidos)
cfg.Server.Static("/uploads", "./uploads", fiber.Static{
ByteRange: true,
})
+12
View File
@@ -0,0 +1,12 @@
package config
// OAuthConfig trae las credenciales de las apps OAuth para conectar cuentas
// de correo (uMind: enviar/leer correo en nombre de un tenant). Sin
// env-default, igual que los secretos JWT — se cargan por variable de
// entorno, no quedan escritos en config.yml.
type OAuthConfig struct {
GoogleClientID string `mapstructure:"GOOGLE_OAUTH_CLIENT_ID" yaml:"google_client_id" env:"GOOGLE_OAUTH_CLIENT_ID"`
GoogleClientSecret string `mapstructure:"GOOGLE_OAUTH_CLIENT_SECRET" yaml:"google_client_secret" env:"GOOGLE_OAUTH_CLIENT_SECRET"`
MSClientID string `mapstructure:"MS_OAUTH_CLIENT_ID" yaml:"ms_client_id" env:"MS_OAUTH_CLIENT_ID"`
MSClientSecret string `mapstructure:"MS_OAUTH_CLIENT_SECRET" yaml:"ms_client_secret" env:"MS_OAUTH_CLIENT_SECRET"`
}
+64
View File
@@ -0,0 +1,64 @@
package config
import (
"log"
"os"
"strings"
)
// Valores que estuvieron publicados en el repositorio y deben considerarse
// comprometidos. Si siguen en uso, cualquiera que haya visto el repo puede
// fabricarse una sesión de administrador o descifrar las contraseñas guardadas.
const (
jwtAppComprometido = "SECRET_APP"
jwtApiComprometido = "SECRET_API"
appKeyComprometida = "1894cde6c936a294a478cff0a9227fd276d86df6573b51af5dc59c9064edf426"
)
// VerificarSecretos se ejecuta al arrancar. Los secretos JWT comprometidos
// abortan el arranque (permiten suplantar a cualquier usuario y el único costo
// de rotarlos es volver a iniciar sesión). La clave de cifrado solo advierte,
// porque cambiarla invalida las contraseñas de integraciones ya guardadas y eso
// requiere volver a capturarlas a mano desde el panel.
func (cfg *AppConfig) VerificarSecretos() {
var criticos []string
if esSecretoDebil(cfg.Token.AppJwtSecret, jwtAppComprometido) {
criticos = append(criticos, "APP_JWT_SECRET")
}
if esSecretoDebil(cfg.Token.ApiJwtSecret, jwtApiComprometido) {
criticos = append(criticos, "API_JWT_SECRET")
}
if len(criticos) > 0 {
log.Printf("[SEGURIDAD] Los siguientes secretos siguen con el valor publicado en el repositorio: %s", strings.Join(criticos, ", "))
log.Printf("[SEGURIDAD] Con ese valor cualquiera puede firmarse una cookie de sesión y entrar como administrador.")
log.Printf("[SEGURIDAD] Define variables de entorno con valores nuevos y aleatorios antes de arrancar, por ejemplo:")
for _, nombre := range criticos {
log.Printf("[SEGURIDAD] %s=$(openssl rand -hex 32)", nombre)
}
log.Fatalf("[SEGURIDAD] Arranque abortado para no exponer el sistema. Al rotarlos, las sesiones abiertas se cierran y hay que volver a iniciar sesión.")
}
if cfg.Server.Key == "" || cfg.Server.Key == appKeyComprometida {
log.Printf("[SEGURIDAD] APP_KEY tiene el valor por defecto publicado en el repositorio.")
log.Printf("[SEGURIDAD] Con esa clave, cualquiera con acceso a la base de datos puede descifrar las contraseñas SMTP y de integraciones.")
log.Printf("[SEGURIDAD] Define APP_KEY=$(openssl rand -hex 32) y vuelve a guardar las contraseñas de SMTP e integraciones desde el panel")
log.Printf("[SEGURIDAD] (al cambiar la clave, las contraseñas cifradas con la anterior dejan de poder descifrarse).")
}
}
// esSecretoDebil indica si el secreto está vacío o es el valor comprometido.
func esSecretoDebil(valor, comprometido string) bool {
v := strings.TrimSpace(valor)
return v == "" || v == comprometido
}
// SecretoDesdeEntorno lee un secreto del entorno con un valor por defecto.
// Se usa para credenciales que no deben vivir en config.yml.
func SecretoDesdeEntorno(nombre, porDefecto string) string {
if v := strings.TrimSpace(os.Getenv(nombre)); v != "" {
return v
}
return porDefecto
}
+11 -8
View File
@@ -9,13 +9,16 @@ import (
)
type Token struct {
Hash string `json:"token"`
Expire int64 `mapstructure:"JWT_EXPIRE" json:"expires_in" yaml:"expires_in"`
AppJwtSecret string `mapstructure:"APP_JWT_SECRET" yaml:"app_jwt_secret"`
ApiJwtSecret string `mapstructure:"API_JWT_SECRET" yaml:"api_jwt_secret"`
Hash string `json:"token"`
Expire int64 `mapstructure:"JWT_EXPIRE" json:"expires_in" yaml:"expires_in"`
// Los secretos JWT firman las cookies de sesión: si se filtran, cualquiera
// puede fabricarse una sesión de administrador válida. El tag env: permite
// definirlos por variable de entorno sin dejarlos escritos en config.yml.
AppJwtSecret string `mapstructure:"APP_JWT_SECRET" yaml:"app_jwt_secret" env:"APP_JWT_SECRET"`
ApiJwtSecret string `mapstructure:"API_JWT_SECRET" yaml:"api_jwt_secret" env:"API_JWT_SECRET"`
}
//CreateToken authenticates the user
// CreateToken authenticates the user
func (t *Token) CreateToken(c *fiber.Ctx, userID uint, secret string, expire ...int64) (*Token, error) {
token := jwt.New(jwt.SigningMethodHS256)
@@ -47,7 +50,7 @@ func (t *Token) CreateToken(c *fiber.Ctx, userID uint, secret string, expire ...
return t, nil
}
//ParseToken returns the users id or error
// ParseToken returns the users id or error
func (t *Token) ParseToken(c *fiber.Ctx, secret string) (uint, error) {
tokenString := c.Cookies("Verify-Rest-Token")
@@ -74,12 +77,12 @@ func (t *Token) ParseToken(c *fiber.Ctx, secret string) (uint, error) {
return uint(claims["id"].(float64)), nil
}
//DeleteToken deletes the jwt token
// DeleteToken deletes the jwt token
func (t *Token) DeleteToken(c *fiber.Ctx) {
c.ClearCookie("Verify-Rest-Token")
}
//RefreshToken refreshes the token
// RefreshToken refreshes the token
func (t *Token) RefreshToken(c *fiber.Ctx, secret string) (*Token, error) {
u, err := t.ParseToken(c, secret)
+25
View File
@@ -0,0 +1,25 @@
package config
import "github.com/gofiber/fiber/v2"
// UploadsGuard decide si una petición puede leer archivos de /uploads.
// Se inyecta desde main.go porque el paquete config no puede importar pkg/auth
// (sería una dependencia circular vía app). Se consulta en cada petición, no al
// registrar la ruta, así que puede definirse después de cargar la configuración.
//
// Mientras esté en nil, /uploads queda cerrado: es preferible que un archivo no
// cargue a que quede expuesto sin autenticación.
var UploadsGuard func(*fiber.Ctx) bool
// uploadsProtegidos bloquea el acceso anónimo a los archivos subidos.
// Antes /uploads se servía como estático público: sabiendo (o adivinando) la
// ruta se podía descargar el RUT de un cliente, una factura o un entregable sin
// iniciar sesión. Los clientes del portal siguen usando los endpoints de
// descarga dedicados, que además validan que el archivo les pertenezca.
func uploadsProtegidos(c *fiber.Ctx) error {
if UploadsGuard != nil && UploadsGuard(c) {
return c.Next()
}
// 404 en vez de 401 para no confirmar si el archivo existe.
return c.Status(fiber.StatusNotFound).SendString("Not found")
}
+505
View File
@@ -0,0 +1,505 @@
<title>Contrato API v1</title>
<style>
/* ── Tokens ───────────────────────────────────────────────────────────────
Paleta anclada al verde de marca de U-Site (#8eb02f) que ya usa el panel,
el widget y los correos. Neutros con sesgo oliva para que el acento no
flote sobre un gris ajeno. Los colores semánticos (crítico / atención /
confirmado) son un juego aparte del acento, a propósito. */
:root {
--ground: #f6f6f2;
--surface: #ffffff;
--surface-sunk: #f0f1ea;
--line: #e0e1d6;
--line-strong: #c9cbba;
--text: #1c1e18;
--text-soft: #5a5d51;
--text-faint: #86897a;
--accent: #63801c;
--accent-soft: #eef3dd;
--crit: #a32a1e;
--crit-soft: #fbe9e6;
--warn: #8a5a06;
--warn-soft: #fbf0d9;
--ok: #2f6b3f;
--ok-soft: #e6f1e6;
--code-bg: #1a1c16;
--code-text: #e8eadd;
--code-dim: #8f947f;
--shadow: 0 1px 2px rgba(28,30,24,.06), 0 8px 24px -12px rgba(28,30,24,.14);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #14150f;
--surface: #1c1e17;
--surface-sunk: #24261d;
--line: #32352a;
--line-strong: #474b3c;
--text: #e9ebdf;
--text-soft: #b0b4a2;
--text-faint: #82866f;
--accent: #a8c94f;
--accent-soft: #2a3118;
--crit: #f08a7c;
--crit-soft: #38201c;
--warn: #e5b45c;
--warn-soft: #362a13;
--ok: #7fc08d;
--ok-soft: #1d2f22;
--code-bg: #0e0f0a;
--code-text: #e8eadd;
--code-dim: #7b8069;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 10px 30px -14px rgba(0,0,0,.7);
}
}
:root[data-theme="dark"] {
--ground: #14150f;
--surface: #1c1e17;
--surface-sunk: #24261d;
--line: #32352a;
--line-strong: #474b3c;
--text: #e9ebdf;
--text-soft: #b0b4a2;
--text-faint: #82866f;
--accent: #a8c94f;
--accent-soft: #2a3118;
--crit: #f08a7c;
--crit-soft: #38201c;
--warn: #e5b45c;
--warn-soft: #362a13;
--ok: #7fc08d;
--ok-soft: #1d2f22;
--code-bg: #0e0f0a;
--code-text: #e8eadd;
--code-dim: #7b8069;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 10px 30px -14px rgba(0,0,0,.7);
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--ground);
color: var(--text);
font-family: ui-sans-serif, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 60rem; margin: 0 auto; padding: 3rem 1.5rem 6rem; }
/* ── Encabezado ─────────────────────────────────────────────────────────── */
.eyebrow {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .6875rem; letter-spacing: .14em; text-transform: uppercase;
color: var(--accent); margin: 0 0 .75rem;
}
h1 {
font-size: clamp(1.9rem, 1.3rem + 2.2vw, 2.75rem);
line-height: 1.1; letter-spacing: -.022em; font-weight: 660;
margin: 0 0 .85rem; text-wrap: balance;
}
.standfirst {
font-size: 1.0625rem; color: var(--text-soft);
max-width: 46rem; margin: 0 0 2rem;
}
.standfirst strong { color: var(--text); font-weight: 620; }
/* ── Resumen ────────────────────────────────────────────────────────────── */
.tally { display: flex; flex-wrap: wrap; gap: .625rem; margin-bottom: 1rem; }
.tally-item {
display: flex; align-items: baseline; gap: .5rem;
background: var(--surface); border: 1px solid var(--line);
border-radius: .5rem; padding: .625rem .875rem; box-shadow: var(--shadow);
}
.tally-n {
font-size: 1.375rem; font-weight: 680; line-height: 1;
font-variant-numeric: tabular-nums;
}
.tally-l { font-size: .8125rem; color: var(--text-soft); }
.tally-item.is-crit { border-color: var(--crit); }
.tally-item.is-crit .tally-n { color: var(--crit); }
.tally-item.is-warn .tally-n { color: var(--warn); }
.tally-item.is-ok .tally-n { color: var(--ok); }
.meta {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .75rem; color: var(--text-faint);
border-top: 1px solid var(--line); padding-top: 1rem; margin-bottom: 2.75rem;
}
.meta code { background: none; padding: 0; color: var(--text-soft); }
/* ── Secciones numeradas ────────────────────────────────────────────────
La numeración no es decorativa: el otro equipo mandó las preguntas
numeradas del 1 al 10 y así se responden en el mismo orden. */
.q {
display: grid; grid-template-columns: 3.25rem 1fr; gap: 0 1.25rem;
padding: 1.75rem 0; border-top: 1px solid var(--line);
}
.q-num {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .875rem; font-weight: 600; color: var(--text-faint);
font-variant-numeric: tabular-nums; padding-top: .3rem;
}
.q-body { min-width: 0; }
.q h2 {
font-size: 1.1875rem; line-height: 1.3; letter-spacing: -.012em;
font-weight: 640; margin: 0 0 .6rem; text-wrap: balance;
}
.q p { margin: 0 0 .85rem; }
.q p:last-child { margin-bottom: 0; }
.q ul { margin: 0 0 .85rem; padding-left: 1.15rem; }
.q li { margin-bottom: .3rem; }
/* Barra de severidad a la izquierda del número */
.q.sev-crit .q-num { color: var(--crit); }
.q.sev-warn .q-num { color: var(--warn); }
.q.sev-ok .q-num { color: var(--ok); }
.chip {
display: inline-block; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .6875rem; letter-spacing: .06em; text-transform: uppercase;
padding: .2rem .5rem; border-radius: .3125rem; margin-bottom: .55rem;
font-weight: 600;
}
.chip-crit { background: var(--crit-soft); color: var(--crit); }
.chip-warn { background: var(--warn-soft); color: var(--warn); }
.chip-ok { background: var(--ok-soft); color: var(--ok); }
.chip-info { background: var(--surface-sunk); color: var(--text-soft); }
/* ── Código ─────────────────────────────────────────────────────────────── */
pre {
background: var(--code-bg); color: var(--code-text);
border-radius: .5rem; padding: .875rem 1rem; margin: 0 0 .85rem;
overflow-x: auto; font-size: .8125rem; line-height: 1.55;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
pre .c { color: var(--code-dim); }
pre .hl { color: #d9e88a; }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .875em; background: var(--surface-sunk);
padding: .1rem .3rem; border-radius: .25rem;
}
.cite {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .75rem; color: var(--text-faint); margin: -.35rem 0 .85rem;
}
.note {
border-left: 2px solid var(--accent); background: var(--accent-soft);
padding: .75rem .9rem; border-radius: 0 .375rem .375rem 0;
margin: 0 0 .85rem; font-size: .9375rem;
}
.note.is-crit { border-left-color: var(--crit); background: var(--crit-soft); }
.note p { margin: 0; }
.note p + p { margin-top: .5rem; }
/* ── Tablas ─────────────────────────────────────────────────────────────── */
.scroll { overflow-x: auto; margin: 0 0 .85rem; }
table { border-collapse: collapse; width: 100%; font-size: .875rem; min-width: 32rem; }
th, td { text-align: left; padding: .5rem .7rem; border-bottom: 1px solid var(--line); vertical-align: top; }
th {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .6875rem; letter-spacing: .07em; text-transform: uppercase;
color: var(--text-faint); font-weight: 600;
border-bottom-color: var(--line-strong);
}
td code { font-size: .8125rem; }
/* ── Cierre ─────────────────────────────────────────────────────────────── */
.todo {
margin-top: 3rem; background: var(--surface); border: 1px solid var(--line);
border-radius: .625rem; padding: 1.5rem 1.75rem; box-shadow: var(--shadow);
}
.todo h2 { font-size: 1.125rem; font-weight: 640; margin: 0 0 .35rem; letter-spacing: -.01em; }
.todo > p { color: var(--text-soft); font-size: .9375rem; margin: 0 0 1.1rem; }
.todo ol { margin: 0; padding-left: 1.15rem; }
.todo li { margin-bottom: .7rem; }
.todo li:last-child { margin-bottom: 0; }
.todo li strong { font-weight: 620; }
a { color: var(--accent); text-underline-offset: 2px; }
a:focus-visible, [tabindex]:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px; border-radius: .2rem;
}
@media (max-width: 34rem) {
.q { grid-template-columns: 1fr; gap: 0; }
.q-num { padding-top: 0; margin-bottom: .35rem; }
}
</style>
<div class="wrap">
<p class="eyebrow">Referencia de integración · admin.u-site.app</p>
<h1>Contrato real de la API v1</h1>
<p class="standfirst">
Las 10 preguntas del equipo integrador, respondidas <strong>leyendo el código del
servidor</strong> en vez de inferirlas desde el cliente. Cada respuesta cita archivo y
línea para que se pueda auditar. Una es crítica y explica por qué la integración
probablemente nunca autenticó.
</p>
<div class="tally">
<div class="tally-item is-crit"><span class="tally-n">1</span><span class="tally-l">crítica</span></div>
<div class="tally-item is-warn"><span class="tally-n">2</span><span class="tally-l">requieren atención</span></div>
<div class="tally-item is-ok"><span class="tally-n">7</span><span class="tally-l">contrato confirmado</span></div>
<div class="tally-item"><span class="tally-n">4</span><span class="tally-l">arreglos de nuestro lado</span></div>
</div>
<p class="meta">
Base de todos los endpoints: <code>https://admin.u-site.app/api/v1/…</code><br>
Montaje: <code>rest/routes/routes.go:10</code> monta <code>/api</code> · <code>rest/routes/api.go:38</code> agrega <code>v1</code>
</p>
<!-- 1 -->
<section class="q sev-ok">
<div class="q-num">01</div>
<div class="q-body">
<span class="chip chip-ok">Su lectura es correcta</span>
<h2><code>expires_in</code> es un timestamp Unix absoluto</h2>
<p>El campo se reusa: entra como duración en segundos y sale como timestamp.</p>
<pre><span class="c">// config/token.go:26-49</span>
t.Expire = ninetyYears <span class="c">// duración, en segundos</span>
expiresIn := time.Now().Add(
time.Duration(t.Expire)*time.Second).Unix()
claims["exp"] = expiresIn
t.Expire = <span class="hl">expiresIn</span> <span class="c">// ← se SOBREESCRIBE</span></pre>
<p>Lo que viaja en la respuesta es <code>expiresIn</code>: segundos desde epoch.</p>
<div class="note is-crit">
<p><strong>Pero hay algo más grave.</strong> <code>Login</code> llama a
<code>CreateToken</code> sin pasarle vencimiento (<code>pkg/auth/user.go:98</code>),
así que aplica el default: <strong>90 años</strong>.</p>
<p>En la práctica ese token nunca expira, y toda la lógica de caché y renovación
del cliente no se ejerce jamás. Es un arreglo nuestro, no de ustedes.</p>
</div>
</div>
</section>
<!-- 2 -->
<section class="q sev-ok">
<div class="q-num">02</div>
<div class="q-body">
<span class="chip chip-ok">Contrato confirmado</span>
<h2>Respuesta de <code>POST /oauth/token</code></h2>
<pre>{
"<span class="hl">token</span>": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"<span class="hl">expires_in</span>": 4626547200
}</pre>
<p class="cite">rest/controllers/api/auth_controller.go:42-45</p>
<ul>
<li>El campo es <code>token</code>, <strong>no</strong> <code>access_token</code>.</li>
<li><code>expires_in</code> está en el nivel raíz.</li>
<li>No hay <code>token_type</code>, ni <code>refresh_token</code>, ni envoltorio.</li>
</ul>
<p>Error de credenciales, siempre HTTP 401:</p>
<pre>{ "error": true, "message": "Invalid Credentials" }</pre>
</div>
</section>
<!-- 3 -->
<section class="q sev-warn">
<div class="q-num">03</div>
<div class="q-body">
<span class="chip chip-warn">Plano, con una excepción</span>
<h2>QR y VCF devuelven JSON sin envoltorio</h2>
<div class="scroll">
<table>
<thead>
<tr><th>Endpoint</th><th>Método</th><th>Respuesta</th></tr>
</thead>
<tbody>
<tr><td><code>/generate-qr-tmp</code></td><td>POST</td><td><code>{"url": "https://…"}</code></td></tr>
<tr><td><code>/generate-url-qr</code></td><td>POST</td><td><code>{"url": "https://…"}</code></td></tr>
<tr><td><code>/generate-vcf</code></td><td>POST</td><td><code>{"success": true, "url": "https://…"}</code></td></tr>
<tr><td><code>/generate-qr</code></td><td>POST</td><td><strong>No es JSON</strong> — imagen binaria <code>image/webp</code></td></tr>
</tbody>
</table>
</div>
<p class="cite">vcard_qr.go:133-135 · vcard_vcf.go:103-106 · vcard_qr.go:169-170</p>
<p>Ojo con el último: <code>generate-qr</code> responde bytes de imagen. Un
<code>json_decode</code> sobre eso falla siempre.</p>
<p>Errores: <code>{"error": "Datos inválidos"}</code> con 400, o
<code>{"error": "&lt;detalle&gt;"}</code> con 500.</p>
</div>
</section>
<!-- 4 -->
<section class="q sev-ok">
<div class="q-num">04</div>
<div class="q-body">
<span class="chip chip-ok">Sí, el envoltorio raro es real</span>
<h2>dLocal devuelve JSON codificado como string</h2>
<p>Nadie lo adivinó mal. La doble codificación existe:</p>
<pre><span class="c">// rest/controllers/api/dlocal_controller.go:42-45</span>
return c.Status(200).JSON(fiber.Map{
"message": "Plan creado exitosamente.",
"response": <span class="hl">string(response)</span>, <span class="c">// ← el JSON, como STRING</span>
})</pre>
<p>Lo que llega:</p>
<pre>{
"message": "Plan creado exitosamente.",
"response": "<span class="hl">{\"id\":\"PLAN-123\",\"status\":\"active\"}</span>"
}</pre>
<p>Hay que hacer <code>json_decode</code> <strong>dos veces</strong>: una del cuerpo y
otra del campo <code>response</code>. Aplica igual a los 7 endpoints dLocal
(líneas 44, 77, 110, 137, 162, 188, 221).</p>
<p>El texto de <code>message</code> cambia según el endpoint, así que no conviene
usarlo para decidir nada.</p>
</div>
</section>
<!-- 5 -->
<section class="q sev-warn">
<div class="q-num">05</div>
<div class="q-body">
<span class="chip chip-warn">Hay dos textos, no uno</span>
<h2>El reintento automático cubre solo la mitad de los casos</h2>
<pre><span class="c">// rest/middlewares/auth.go:271-286</span>
token := c.Cookies("Verify-Rest-Token")
if token == "" {
return c.Status(401).JSON(<span class="hl">"Token not found"</span>) <span class="c">// falta la cookie</span>
}
… ErrorHandler:
return ctx.Status(401).JSON(<span class="hl">"Invalid Attempt"</span>) <span class="c">// cookie inválida o vencida</span></pre>
<p>Los dos son <strong>JSON string, con comillas incluidas</strong> — el cuerpo
literal es <code>"Invalid Attempt"</code>, 16 bytes, no el texto pelado.</p>
<div class="note">
<p><strong>Recomendación:</strong> reintentar ante cualquier 401, sin mirar el
texto. Es más robusto y no depende de una redacción que puede cambiar.</p>
</div>
</div>
</section>
<!-- 6 -->
<section class="q sev-ok">
<div class="q-num">06</div>
<div class="q-body">
<span class="chip chip-ok">Una sola raíz</span>
<h2>No hay hosts separados por grupo</h2>
<p>Todo cuelga de <code>https://admin.u-site.app/api/v1/</code>. Lo correcto es
guardar <strong>la raíz</strong> y concatenar la ruta, en vez de guardar la URL del
token y derivar las demás con <code>str_replace</code>.</p>
<pre>POST /api/v1/oauth/token
POST /api/v1/generate-qr-tmp
POST /api/v1/generate-qr <span class="c">(imagen)</span>
POST /api/v1/generate-vcf
POST /api/v1/generate-url-qr
POST /api/v1/dlocal/subscription/crear-plan
GET /api/v1/dlocal/subscription/ver-plan/:planID
PATCH /api/v1/dlocal/subscription/actualizar-plan/:planID
GET /api/v1/dlocal/subscription/plan/all
PATCH /api/v1/dlocal/subscription/plan/:planId/subscription/:subscriptionId/deactivate
GET /api/v1/dlocal/subscription/:subscriptionId/execution/:invoiceId
POST /api/v1/dlocal/payment/crear-pago
POST /api/v1/rapyd/wallet/create</pre>
</div>
</section>
<!-- 7 -->
<section class="q sev-crit">
<div class="q-num">07</div>
<div class="q-body">
<span class="chip chip-crit">Crítico</span>
<h2>El header <code>Cookie</code> es obligatorio, y hoy está roto</h2>
<p>La API <strong>no acepta Bearer</strong>. Autentica exclusivamente por cookie:</p>
<pre><span class="c">// rest/middlewares/auth.go:272-281</span>
token := c.Cookies(<span class="hl">"Verify-Rest-Token"</span>)
if token == "" { return c.Status(401).JSON("Token not found") }
TokenLookup: <span class="hl">"cookie:Verify-Rest-Token"</span></pre>
<div class="note is-crit">
<p>Con el placeholder literal <code>...</code> sin completar, <strong>los 12
endpoints autenticados devuelven 401</strong>. Si algo funciona hoy, es porque el
cliente HTTP tiene cookie jar y reusa la cookie que <code>POST /oauth/token</code>
deja seteada en la respuesta (<code>config/token.go:40-47</code>).</p>
</div>
<ul>
<li>Mandar el token en el body o como <code>Authorization: Bearer</code>
<strong>no autentica nada</strong>.</li>
<li><code>session_id</code> <strong>no hace falta</strong>: el middleware no lo
mira. Se puede quitar.</li>
</ul>
<p>Lo correcto: tomar el <code>token</code> de la respuesta de <code>oauth/token</code>
y mandarlo como <code>Cookie: Verify-Rest-Token=&lt;token&gt;</code>, o dejar que el
cliente maneje cookies solo.</p>
</div>
</section>
<!-- 8 -->
<section class="q sev-ok">
<div class="q-num">08</div>
<div class="q-body">
<span class="chip chip-info">No aplica</span>
<h2>No hay control por IP en <code>/api/v1</code></h2>
<p>Solo se valida la cookie JWT. No hay allowlist; no hace falta registrar la IP
del servidor de producción.</p>
<p>Sí existe validación por IP, pero en <strong>otra</strong> API:
<code>/api/v2</code> usa <code>ApiKey</code> con IP obligatoria y scopes, y
<code>/api/v1/pagos-externos</code> usa <code>AuthServicioPago</code> — ambas
excluidas de <code>AuthApi</code> (<code>auth.go:266-268</code>).</p>
<div class="note">
<p>Si prefieren un esquema con IP fija y token que no viva en una cookie,
<strong><code>/api/v2</code> es el camino</strong>, y conviene migrar ahí en vez de
arreglar el actual.</p>
</div>
</div>
</section>
<!-- 9 -->
<section class="q sev-warn">
<div class="q-num">09</div>
<div class="q-body">
<span class="chip chip-warn">Fuera de este documento</span>
<h2>Credenciales</h2>
<p>No van acá. Son las de un usuario real de la tabla <code>users</code>
(<code>login.CheckLogin()</code>, <code>auth_controller.go:28</code>). Que se
entreguen por un canal seguro.</p>
<p>Dado que llevan 15 meses sin rotar, conviene <strong>crear un usuario dedicado a
la integración</strong> en vez de reusar el de una persona.</p>
</div>
</section>
<!-- 10 -->
<section class="q sev-ok">
<div class="q-num">10</div>
<div class="q-body">
<span class="chip chip-info">Existen, pero no para esto</span>
<h2>No hay callback saliente hacia ustedes</h2>
<p>Hay webhooks entrantes de pago ya montados
(<code>rest/routes/publicas.go:22-33</code>): <code>/webhooks/bold</code>,
<code>/webhooks/dlocal</code>, <code>/webhooks/paypal</code>, más Coolify y Telegram.</p>
<p>Pero sirven para que <strong>la pasarela nos avise a nosotros</strong>, no para
avisarle a un tercero. Un callback hacia su sistema es desarrollo nuevo.</p>
<p>Mientras tanto, la alternativa ya construida es
<code>/api/v1/pagos-externos</code>, pensada justo para apps de terceros:</p>
<pre>GET /api/v1/pagos-externos/pasarelas
POST /api/v1/pagos-externos/solicitar
GET /api/v1/pagos-externos/:referencia/estado</pre>
<div class="note">
<p>Usa token Bearer + IP (no cookie), así que evita todo el problema del punto 07.
<strong>Para una integración nueva, recomendamos esta</strong> y no los endpoints
dLocal directos.</p>
</div>
</div>
</section>
<div class="todo">
<h2>Pendientes de nuestro lado</h2>
<p>No son preguntas: son cosas a corregir en <code>admin.u-site.app</code>.</p>
<ol>
<li><strong>Tokens de 90 años</strong> (<code>config/token.go:26</code>). Un token
filtrado es acceso permanente. Ponerle un vencimiento razonable — y recién ahí la
caché y el reintento del cliente van a tener sentido.</li>
<li><strong>La cookie se emite con <code>Secure: false</code></strong>
(<code>config/token.go:44</code>), así que viaja también por HTTP plano.</li>
<li><strong>Autenticación por cookie en una API máquina-a-máquina</strong> es el
problema de fondo: obliga a manejar cookie jar y no permite allowlist por IP.
<code>/api/v2</code> ya lo resuelve bien.</li>
<li><strong>La doble codificación de dLocal</strong> no aporta nada; devolver el
JSON anidado directo sería un cambio compatible si se versiona.</li>
</ol>
</div>
</div>
+217
View File
@@ -0,0 +1,217 @@
# Contrato real de la API v1 de `admin.u-site.app`
Respuestas verificadas **leyendo el código del servidor**, no inferidas desde
el cliente. Cada punto cita el archivo y la línea para que se pueda auditar.
Base de todos los endpoints: `https://admin.u-site.app/api/v1/…`
(`rest/routes/routes.go:10` monta `/api`, `rest/routes/api.go:38` agrega `v1`).
---
## 1. Formato de `expires_in` → **timestamp Unix absoluto**
Su interpretación es la correcta. En `config/token.go:22-49`:
```go
t.Expire = ninetyYears // acá es duración en segundos
expiresIn := time.Now().Add(time.Duration(t.Expire)*time.Second).Unix()
claims["exp"] = expiresIn
t.Expire = expiresIn // ← se SOBREESCRIBE con el absoluto
```
El campo se reusa: entra como duración y sale como timestamp. Lo que viaja en
la respuesta es `expiresIn`, o sea **segundos desde epoch**.
> **Pero hay algo más importante:** cuando `Login` llama a `CreateToken` no le
> pasa vencimiento (`pkg/auth/user.go:98`), así que aplica el default de
> `ninetyYears` — **90 años**. En la práctica el token de esa integración
> nunca expira, y toda la lógica de caché y renovación no se ejerce jamás.
> Esto hay que cambiarlo del lado nuestro (ver Pendientes).
## 2. Respuesta real de `POST /api/v1/oauth/token`
`rest/controllers/api/auth_controller.go:42-45`:
```json
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 4626547200
}
```
- El campo es **`token`**, no `access_token`.
- `expires_in` está en el nivel raíz.
- No hay `token_type`, ni `refresh_token`, ni envoltorio.
Errores (todos con HTTP 401), `auth_controller.go:15-40`:
```json
{ "error": true, "message": "Invalid Credentials" }
```
## 3. QR y VCF → **JSON plano**
| Endpoint | Método | Respuesta |
|---|---|---|
| `/api/v1/generate-qr-tmp` | POST | `{"url": "https://…"}` |
| `/api/v1/generate-url-qr` | POST | `{"url": "https://…"}` |
| `/api/v1/generate-vcf` | POST | `{"success": true, "url": "https://…"}` |
| `/api/v1/generate-qr` | POST | **no es JSON**: devuelve la imagen binaria `Content-Type: image/webp` |
`vcard_qr.go:133-135`, `vcard_vcf.go:103-106`, `vcard_qr.go:169-170`.
Ojo con el último: `generate-qr` responde bytes de imagen, no JSON. Si el
cliente hace `json_decode` de eso, falla siempre.
Errores: `{"error": "Datos inválidos"}` con 400, o `{"error": "<detalle>"}` con 500.
## 4. dLocal → **sí, el envoltorio raro es real**
Nadie lo adivinó mal. `rest/controllers/api/dlocal_controller.go:42-45`:
```go
return c.Status(200).JSON(fiber.Map{
"message": "Plan creado exitosamente.",
"response": string(response), // ← el JSON de dLocal, como STRING
})
```
O sea, doble codificación real:
```json
{
"message": "Plan creado exitosamente.",
"response": "{\"id\":\"PLAN-123\",\"status\":\"active\"}"
}
```
Hay que hacer `json_decode` **dos veces**: una del cuerpo y otra del campo
`response`. Aplica igual a los 7 endpoints dLocal (líneas 44, 77, 110, 137,
162, 188, 221). El campo `message` cambia de texto según el endpoint, así que
no conviene usarlo para decidir nada.
## 5. Cuerpo de error de sesión inválida → **hay DOS textos distintos**
Acá está el problema que sospechaban. `rest/middlewares/auth.go:271-286`:
```go
token := c.Cookies("Verify-Rest-Token")
if token == "" {
return c.Status(401).JSON("Token not found") // ← falta la cookie
}
ErrorHandler: return ctx.Status(401).JSON("Invalid Attempt") // ← cookie inválida/vencida
```
- Sin cookie → cuerpo `"Token not found"`
- Cookie presente pero inválida o vencida → cuerpo `"Invalid Attempt"`
Los dos son **JSON string, con comillas incluidas** — el cuerpo literal es
`"Invalid Attempt"`, 16 bytes, no el texto pelado.
Su reintento solo cubre el segundo caso. **Recomendación: reintentar ante
cualquier 401**, sin mirar el texto. Es más robusto y no depende de una
redacción que puede cambiar.
## 6. URLs base → **una sola raíz, sin derivar por `str_replace`**
Todos cuelgan de `https://admin.u-site.app/api/v1/`. No hay hosts separados
por grupo, así que lo correcto es guardar **la raíz** y concatenar la ruta,
no guardar la URL del token y derivar las demás:
```
POST /api/v1/oauth/token
POST /api/v1/generate-qr-tmp
POST /api/v1/generate-qr (devuelve imagen)
POST /api/v1/generate-vcf
POST /api/v1/generate-url-qr
POST /api/v1/dlocal/subscription/crear-plan
GET /api/v1/dlocal/subscription/ver-plan/:planID
PATCH /api/v1/dlocal/subscription/actualizar-plan/:planID
GET /api/v1/dlocal/subscription/plan/all
PATCH /api/v1/dlocal/subscription/plan/:planId/subscription/:subscriptionId/deactivate
GET /api/v1/dlocal/subscription/:subscriptionId/execution/:invoiceId
POST /api/v1/dlocal/payment/crear-pago
POST /api/v1/rapyd/wallet/create
```
## 7. El header `Cookie` → **es OBLIGATORIO, y hoy está roto**
Esta es la más crítica de las 10.
La API **no acepta Bearer**. `AuthApi()` lee exclusivamente la cookie
(`rest/middlewares/auth.go:272-281`):
```go
token := c.Cookies("Verify-Rest-Token")
if token == "" { return c.Status(401).JSON("Token not found") }
TokenLookup: "cookie:Verify-Rest-Token"
```
Consecuencias:
- Mandar el token en el body o como `Authorization: Bearer` **no autentica nada**.
- Con el placeholder literal `...` sin completar, **los 12 endpoints
autenticados devuelven 401 "Token not found"**. Si algo de eso "funciona"
hoy, es porque el cliente HTTP tiene cookie jar y está reusando la cookie
que `POST /oauth/token` deja seteada en la respuesta (`config/token.go:40-47`).
- **`session_id` no hace falta**: el middleware no lo mira. Se puede quitar.
Lo correcto es tomar el `token` de la respuesta de `oauth/token` y mandarlo
como `Cookie: Verify-Rest-Token=<token>`, o dejar que el cliente maneje
cookies solo.
## 8. Control de acceso por IP → **no, para `/api/v1` no hay**
`/api/v1` solo valida la cookie JWT. No hay allowlist de IP; no hace falta
registrar la IP del servidor de producción.
Sí existe validación por IP, pero en **otra** API: `/api/v2` usa `ApiKey` con
IP obligatoria y scopes, y `/api/v1/pagos-externos` usa `AuthServicioPago`
(ambas excluidas de `AuthApi` en `auth.go:266-268`). Si prefieren un esquema
con IP fija y token que no vive en una cookie, **`/api/v2` es el camino** y
vale la pena migrar ahí en vez de arreglar el actual.
## 9. Usuario y contraseña
No van en este documento. Son las credenciales de un usuario real de la tabla
`users` (`login.CheckLogin()`, `auth_controller.go:28`). Que las entreguen por
un canal seguro y, dado que llevan 15 meses sin rotar, **conviene crear un
usuario dedicado a la integración** en vez de reusar uno de persona.
## 10. Webhooks → **sí existen, pero no para esto**
Hay webhooks entrantes de pago ya montados (`rest/routes/publicas.go:22-33`):
`/webhooks/bold`, `/webhooks/dlocal`, `/webhooks/paypal`, más Coolify y
Telegram.
Pero son para que **la pasarela nos avise a nosotros**, no para avisarle a un
tercero. Hoy **no existe** un callback saliente hacia el sistema de ustedes ni
para pagos ni para QR. Si lo necesitan, es desarrollo nuevo de nuestro lado.
Mientras tanto, la alternativa ya construida es `/api/v1/pagos-externos`
(`api.go:32-34`), pensada justo para apps de terceros:
```
GET /api/v1/pagos-externos/pasarelas
POST /api/v1/pagos-externos/solicitar
GET /api/v1/pagos-externos/:referencia/estado
```
Usa token Bearer + IP (no cookie), así que evita todo el problema del punto 7.
**Para una integración nueva, recomendamos esta y no los endpoints dLocal
directos.**
---
## Pendientes de nuestro lado (no son preguntas, son cosas a corregir)
1. **Tokens de 90 años** (`config/token.go:26`). Un token filtrado es acceso
permanente. Hay que ponerle un vencimiento razonable — y recién ahí la
caché y el reintento del cliente van a tener sentido.
2. **La cookie se emite con `Secure: false`** (`config/token.go:44`), así que
viaja también por HTTP plano.
3. **Autenticación por cookie en una API máquina-a-máquina** es el problema de
fondo: obliga a manejar cookie jar y no permite IP allowlist. `/api/v2` ya
resuelve esto bien.
4. **La doble codificación de dLocal** (punto 4) no aporta nada; devolver el
JSON anidado directo sería un cambio compatible si se versiona.
+17 -8
View File
@@ -1,6 +1,6 @@
module github.com/sujit-baniya/fiber-boilerplate
go 1.25.0
go 1.26
require (
github.com/BurntSushi/toml v1.6.0 // indirect
@@ -25,12 +25,11 @@ require (
github.com/pyroscope-io/pyroscope v0.37.2
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
github.com/sujit-baniya/flash v0.1.9
github.com/sujit-baniya/ip v0.0.10
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/valyala/bytebufferpool v1.0.0
github.com/xhit/go-simple-mail/v2 v2.16.0
golang.org/x/crypto v0.51.0
golang.org/x/sys v0.44.0 // indirect
golang.org/x/sys v0.47.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
gorm.io/driver/mysql v1.5.7
@@ -43,7 +42,11 @@ require (
require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/chai2010/webp v1.4.0
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe
github.com/chromedp/chromedp v0.16.0
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/go-sql-driver/mysql v1.8.1
github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.22
github.com/microsoft/go-mssqldb v1.7.2
@@ -53,12 +56,16 @@ require (
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.9.4
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/valyala/fasthttp v1.56.0
go.mongodb.org/mongo-driver v1.17.9
golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.23.0
gorm.io/driver/sqlite v1.5.6
gorm.io/driver/sqlserver v1.5.3
)
require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
@@ -66,19 +73,26 @@ require (
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
github.com/casbin/govaluate v1.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-test/deep v1.1.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
@@ -88,7 +102,6 @@ require (
github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gookit/filter v1.2.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
@@ -116,7 +129,6 @@ require (
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pyroscope-io/dotnetdiag v1.2.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -126,7 +138,6 @@ require (
github.com/tinylib/msgp v1.6.1 // indirect
github.com/tklauser/numcpus v0.8.0 // indirect
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect
github.com/valyala/fasthttp v1.56.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
@@ -138,8 +149,6 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
+28 -20
View File
@@ -33,6 +33,8 @@ cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvf
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
@@ -95,8 +97,6 @@ github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6Ct
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
@@ -137,6 +137,12 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe h1:PmhRwLZ8qLtldQCBiydwdPFJI8WVQ936ux1cpgHLRb8=
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -169,6 +175,12 @@ github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5O
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU=
github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -206,6 +218,8 @@ github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7b
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
@@ -236,9 +250,14 @@ github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncV
github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
github.com/gobuffalo/here v0.6.7 h1:hpfhh+kt2y9JLDfhYUxxCRxQol540jsVfKUZzjlbp8o=
github.com/gobuffalo/here v0.6.7/go.mod h1:vuCfanjqckTuRlqAitJz6QC4ABNnS27wLb816UhsPcc=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.1.0/go.mod h1:aG+lMkwy3LyVit4CnmYUbUdgjpc3UYOltvlJZ78rgQ0=
github.com/gofiber/fiber/v2 v2.9.0/go.mod h1:Ah3IJikrKNRepl/HuVawppS25X7FWohwfCSRn7kJG28=
github.com/gofiber/fiber/v2 v2.44.0/go.mod h1:VTMtb/au8g01iqvHyaCzftuM/xmZgKOZCtFzz6CdV9w=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
@@ -439,9 +458,6 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.11.8/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
@@ -466,6 +482,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
@@ -556,9 +574,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk=
github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0=
github.com/oschwald/maxminddb-golang v1.8.0/go.mod h1:RXZtst0N6+FY/3qCNmZMBApR19cdQj43/NM9VkrNAis=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
@@ -669,8 +686,6 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/sujit-baniya/flash v0.1.9 h1:9+g9GrauXF8JH7ivoG4XBjl04r+vNkB9ZFN+5RKTugo=
github.com/sujit-baniya/flash v0.1.9/go.mod h1:nNyzxxxU11ctsPe99BhV39kP6/jcfoRDp4T7P5m4K4E=
github.com/sujit-baniya/ip v0.0.10 h1:4FxdNgibzkDGS6B1+2//o4nStxmvD11i3NEgljh6SLk=
github.com/sujit-baniya/ip v0.0.10/go.mod h1:9wktAv0khHVF2t6lIWRbNV4ScqcHSi92pc5RfBb9YV8=
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
@@ -687,8 +702,6 @@ github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqri
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA=
github.com/valyala/fasthttp v1.23.0/go.mod h1:0mw2RjXGOzxf4NL2jni3gUQ7LfjjUSiG5sskOUUSEpU=
github.com/valyala/fasthttp v1.24.0/go.mod h1:0mw2RjXGOzxf4NL2jni3gUQ7LfjjUSiG5sskOUUSEpU=
github.com/valyala/fasthttp v1.45.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
github.com/valyala/fasthttp v1.56.0 h1:bEZdJev/6LCBlpdORfrLu/WOZXXxvrUQSiyniuaoW8U=
github.com/valyala/fasthttp v1.56.0/go.mod h1:sReBt3XZVnudxuLOx4J/fMrJVorWRiWY2koQKgABiVI=
@@ -753,7 +766,6 @@ golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
@@ -847,7 +859,6 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226101413-39120d07d75e/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
@@ -924,7 +935,6 @@ golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -952,7 +962,6 @@ golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -987,9 +996,8 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
+97 -5
View File
@@ -3,11 +3,15 @@ package main
import (
"flag"
"log"
"os"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/pyroscope-io/pyroscope/pkg/agent/profiler"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/config"
"github.com/sujit-baniya/fiber-boilerplate/migrations"
"github.com/sujit-baniya/fiber-boilerplate/pkg/auth"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
"github.com/sujit-baniya/fiber-boilerplate/rest/routes"
@@ -33,6 +37,13 @@ func main() {
// Actualizar versión del servidor
app.Http.Server.Version = app.Version
// Los archivos de /uploads dejan de ser públicos: solo se sirven a usuarios
// con sesión iniciada en el panel. Los clientes del portal siguen bajando sus
// documentos por los endpoints dedicados, que validan que les pertenezcan.
config.UploadsGuard = func(c *fiber.Ctx) bool {
return auth.IsLoggedIn(c)
}
// Habilitar CORS
app.Http.Server.App.Use(cors.New(cors.Config{
AllowOrigins: "*",
@@ -50,8 +61,13 @@ func main() {
migrations.MigratePortal()
// Crear tabla de Landing Generator
migrations.MigrateLanding()
// Crear tablas de integraciones y pasarelas si no existen
app.Http.Database.DB.AutoMigrate(
// Crear tablas de integraciones y pasarelas si no existen.
// AutoMigrate(a, b, c...) se detiene en el primer modelo que falle: todo lo
// que viene después en la lista se queda sin tabla, en silencio (fue la
// causa real de que plantillas_documento/tarifas desaparecieran de
// producción — un modelo antes en la lista falló y todo lo posterior no se
// llegó a crear). Migrando uno por uno, un modelo roto no bloquea al resto.
modelosBase := []interface{}{
// Tablas base del sistema (columnas nuevas)
&models.Users{},
&models.Roles{},
@@ -86,8 +102,13 @@ func main() {
&models.PortalPasswordResetToken{},
// Integración VCard API Admin
&models.VcardApiConfig{},
// Integración Coolify
// Integración Coolify (multi-instancia)
&models.CoolifyConfig{},
// AI Config: campos del agente Telegram
&models.AiConfig{},
// Agente Telegram: historial y chats autorizados
&models.TelegramAgentHistory{},
&models.TelegramAgentAuth{},
// Servidores: nuevos campos de agente + tabla join de integraciones
&models.Servidor{},
&models.ConxDb{},
@@ -113,7 +134,41 @@ func main() {
// Tablero de tareas
&models.Tarea{},
&models.TareaComentario{},
)
// Pagos externos: API de cobros para aplicaciones de terceros
&models.ServicioPagoExterno{},
&models.SolicitudPagoExterna{},
// Soporte: webhook de correo entrante
&models.SoporteWebhookConfig{},
// Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA
&models.PlantillaDocumento{},
&models.Tarifa{},
&models.DocumentoGenerado{},
&models.Arquitectura{},
// Vinculación de Telegram para staff interno
&models.TelegramStaffToken{},
// uMind: chat con IA embebible por tenant (widget web + RAG)
&models.UmindTenant{},
&models.UmindAgente{},
&models.UmindDocumento{},
&models.UmindChunk{},
&models.UmindMensaje{},
&models.UmindHerramienta{},
&models.UmindCanal{},
&models.UmindConexion{},
&models.UmindEventoLog{},
&models.UmindPlan{},
&models.UmindUso{},
// API Keys de /api/v2 (token + IP obligatoria + scopes)
&models.ApiKey{},
// Integraciones: OCR y transcripción de audio (servicios propios)
&models.OcrConfig{},
&models.WhisperAsrConfig{},
}
for _, m := range modelosBase {
if err := app.Http.Database.DB.AutoMigrate(m); err != nil {
log.Printf("[MIGRATE] Error migrando %T: %v", m, err)
}
}
// Seed automático (idempotente) de módulos del sistema
migrations.SeedRenovaciones()
migrations.SeedIntegraciones()
@@ -128,11 +183,34 @@ func main() {
migrations.SeedShield()
migrations.SeedPartnerRecursos()
models.SeedContabilidad()
models.SeedBalanceData()
// SeedBalanceData carga transacciones contables reales de 2026. Ya no se
// ejecuta en cada arranque: si el contador editaba o borraba una de esas
// filas, el siguiente reinicio la recreaba y el balance quedaba duplicado.
// Para recargarla puntualmente: SEED_BALANCE=1 ./apiv2
if os.Getenv("SEED_BALANCE") == "1" {
log.Println("[SEED] SEED_BALANCE=1 — cargando datos de balance")
models.SeedBalanceData()
}
migrations.SeedContabilidadMenu()
migrations.SeedWebSms()
migrations.SeedUrlMonitor()
migrations.SeedTareas()
migrations.SeedPagosExternos()
migrations.SeedAutomatizacionIA()
migrations.SeedUmind()
migrations.SeedDocumentacion()
// Va antes de MigrarUmindAgentes: libera las columnas huérfanas que
// dejó el refactor multi-agente. Sin esto no se puede insertar nada en
// uMind (ver el comentario de la función).
migrations.LiberarColumnasHuerfanasUmind()
migrations.IndicesUnicosMessageID()
migrations.MigrarUmindAgentes()
migrations.SeedApiKeys()
if n, err := models.RepararEstadosTareaInvalidos(); err != nil {
log.Printf("[FIX] Error reparando estados de tareas: %v", err)
} else if n > 0 {
log.Printf("[FIX] %d tarea(s) con estado inválido reparadas (ahora visibles en el Kanban)", n)
}
// Cargar config SMTP desde BD (sobreescribe valores del .env/config.yml si hay registro activo)
if smtpCfg, err := models.GetSmtpConfig(); err == nil {
app.Http.Mail.Host = smtpCfg.Host
@@ -152,6 +230,20 @@ func main() {
defer services.DetenerCron()
// Cargar rutas
routes.LoadRoutes(app.Http.Server.App)
// Con las rutas ya montadas se puede avisar de los ítems del menú que
// apuntan a una URL inexistente — el síntoma es un 404 que parece un
// problema de permisos.
rutasGET := map[string]bool{}
for _, capa := range app.Http.Server.Stack() {
for _, r := range capa {
if r.Method == "GET" {
rutasGET[r.Path] = true
}
}
}
migrations.VerificarUrlsDeSubmodulos(rutasGET)
app.Http.Route404()
log.Fatal(app.Http.Server.ServeWithGraceFullShutdown())
}
+635 -82
View File
@@ -3,6 +3,7 @@ package migrations
import (
"fmt"
"log"
"strings"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
@@ -25,8 +26,11 @@ type Session struct {
func Migrate() {
log.Println("Initiating migration...")
// Migrate the main application models
if err := app.Http.Database.DB.Migrator().AutoMigrate(
// Migrate the main application models.
// Un solo AutoMigrate(a, b, c...) se detiene en el primer modelo que falle:
// todo lo que viene después en la lista se queda sin tabla. Migrando uno por
// uno, un modelo roto no bloquea al resto (mismo fix que en main.go).
modelosPrincipales := []interface{}{
&models.Roles{},
&models.Users{},
&models.Modules{},
@@ -104,8 +108,38 @@ func Migrate() {
// Tablero de tareas
&models.Tarea{},
&models.TareaComentario{},
); err != nil {
log.Fatalf("Error during main migration: %v", err)
// Pagos externos: API de cobros para aplicaciones de terceros
&models.ServicioPagoExterno{},
&models.SolicitudPagoExterna{},
// Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA
&models.PlantillaDocumento{},
&models.Tarifa{},
&models.DocumentoGenerado{},
&models.Arquitectura{},
// Vinculación de Telegram para staff interno
&models.TelegramStaffToken{},
// uMind: chat con IA embebible por tenant (widget web + RAG)
&models.UmindTenant{},
&models.UmindAgente{},
&models.UmindDocumento{},
&models.UmindChunk{},
&models.UmindMensaje{},
&models.UmindHerramienta{},
&models.UmindCanal{},
&models.UmindConexion{},
&models.UmindEventoLog{},
&models.UmindPlan{},
&models.UmindUso{},
// API Keys de /api/v2 (token + IP obligatoria + scopes)
&models.ApiKey{},
// Integraciones: OCR y transcripción de audio (servicios propios)
&models.OcrConfig{},
&models.WhisperAsrConfig{},
}
for _, m := range modelosPrincipales {
if err := app.Http.Database.DB.Migrator().AutoMigrate(m); err != nil {
log.Printf("[MIGRATE] Error migrando %T: %v", m, err)
}
}
// Open (or create) a SQLite database for sessions
@@ -150,9 +184,49 @@ func Migrate() {
// Agregar submódulo "Partner Recursos" al módulo Portal de Clientes
SeedPartnerRecursos()
// Crear módulo y submódulos de Automatización IA (cotizaciones, contratos, asistente)
SeedAutomatizacionIA()
log.Println("Migration Completed...")
}
// asignarSubmodulosSiFaltan agrega submódulos a un rol solo si el rol todavía no
// tiene NINGUNO de ellos.
//
// Antes los seeds hacían Append incondicional en cada arranque: si un admin le
// quitaba un módulo a un rol desde el panel, el siguiente despliegue se lo
// devolvía y la gestión de permisos era, en la práctica, imposible de sostener.
func asignarSubmodulosSiFaltan(rol *models.Roles, subs []models.Submodules) {
if rol == nil || len(subs) == 0 {
return
}
db := app.Http.Database.DB
var ids []uint
for _, s := range subs {
if s.ID > 0 {
ids = append(ids, s.ID)
}
}
if len(ids) == 0 {
return
}
var yaAsignados int64
db.Table("roles_submodules").
Where("roles_id = ? AND submodules_id IN ?", rol.ID, ids).
Count(&yaAsignados)
if yaAsignados > 0 {
// El rol ya conoce este grupo de submódulos: lo que tenga hoy es una
// decisión del administrador y no se toca.
return
}
if err := db.Model(rol).Association("Submodules").Append(&subs); err != nil {
log.Printf("[SEED] Error asignando submódulos al rol %s: %v", rol.Name, err)
}
}
// SeedStatuspage agrega el submódulo "Statuspage" (Atlassian) al módulo "Integraciones"
// y lo asigna a todos los roles. Es idempotente.
func SeedStatuspage() {
@@ -166,9 +240,15 @@ func SeedStatuspage() {
}
entries := []struct{ title, desc, url string }{
{"Statuspage", "Monitoreo de estado de servicios vía Atlassian Statuspage", "/app/statuspage"},
// La página real es la pública /status (rest/routes/publicas.go):
// /app/statuspage nunca existió y el ítem daba 404 al entrar.
{"Statuspage", "Monitoreo de estado de servicios vía Atlassian Statuspage", "/status"},
}
// La URL vieja apuntaba a /app/statuspage, que no existe: se corrige el
// registro ya creado en vez de dejar un ítem de menú que da 404.
db.Model(&models.Submodules{}).Where("url = ?", "/app/statuspage").Update("url", "/status")
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
@@ -191,18 +271,15 @@ func SeedStatuspage() {
insertados = append(insertados, sub)
}
var roles []models.Roles
if err := db.Find(&roles).Error; err != nil {
log.Printf("[SEED] Error obteniendo roles: %v", err)
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando submódulo Statuspage al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Submódulo Statuspage asignado al rol '%s'", rol.Name)
}
}
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Println("[SEED] Seed de Statuspage completado.")
}
@@ -397,13 +474,87 @@ func SeedRenovaciones() {
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
db.Model(&rolAdmin).Association("Submodules").Append(&insertados)
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Printf("[SEED] Submódulos de Renovaciones asignados al rol 'Administrador'")
}
log.Println("[SEED] Seed de Renovaciones completado.")
}
// SeedAutomatizacionIA inserta el módulo "Automatización IA" y sus submódulos
// (Asistente, Plantillas de Documento, Tarifas) si no existen, y los asigna al
// rol Administrador. Es idempotente.
func SeedAutomatizacionIA() {
db := app.Http.Database.DB
// 1. Crear/obtener módulo
var modulo models.Modules
result := db.Where("title = ?", "Automatización IA").First(&modulo)
if result.Error != nil {
modulo = models.Modules{
Title: "Automatización IA",
Description: "Cotizaciones, contratos, actas y cuentas de cobro generados por IA",
ModifiedAt: time.Now(),
}
if err := db.Create(&modulo).Error; err != nil {
log.Printf("[SEED] Error creando módulo Automatización IA: %v", err)
return
}
log.Printf("[SEED] Módulo 'Automatización IA' creado con ID %d", modulo.ID)
} else {
log.Printf("[SEED] Módulo 'Automatización IA' ya existe (ID %d)", modulo.ID)
}
// 2. Definir submódulos
entries := []struct{ title, desc, url string }{
{"Asistente", "Chat propio: mismo motor y tools que el bot de Telegram", "/app/asistente"},
{"Chats autorizados", "Quién puede hablarle al bot de Telegram y ejecutar acciones", "/app/agente/chats-autorizados"},
{"Plantillas de Documento", "Fuente de verdad de cotización, contrato, acta y cuenta de cobro", "/app/automatizacion/plantillas"},
{"Tarifas", "Valor por hora, licencias, VMs y márgenes usados al cotizar", "/app/automatizacion/tarifas"},
}
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
} else {
if sub.ModuleId != modulo.ID {
if err := db.Model(&sub).Update("module_id", modulo.ID).Error; err != nil {
log.Printf("[SEED] Error actualizando module_id de '%s': %v", sub.Title, err)
} else {
log.Printf("[SEED] Submódulo '%s' reasignado a módulo Automatización IA", sub.Title)
}
} else {
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
}
}
insertados = append(insertados, sub)
}
// 3. Asignar solo al rol Administrador
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Printf("[SEED] Submódulos de Automatización IA asignados al rol 'Administrador'")
}
log.Println("[SEED] Seed de Automatización IA completado.")
}
// SeedIntegraciones crea el módulo "Integraciones" con los submódulos
// de Hostinger y Cloudflare, y los asigna a todos los roles existentes.
// Es idempotente: si el registro ya existe (por URL) lo reutiliza sin duplicar.
@@ -432,6 +583,8 @@ func SeedIntegraciones() {
{"Hostinger", "Panel de VPS, dominios, hosting y DNS de Hostinger", "/app/hostinger"},
{"Cloudflare", "Gestión de zonas, DNS, SSL y firewall en Cloudflare", "/app/cloudflare"},
{"VCard API", "Integración con el sistema VCard externo (Laravel + Sanctum): usuarios, membresías, vcards, pagos y más", "/app/vcard-api"},
{"OCR", "Extracción de texto de imágenes (comprobantes, capturas) vía servicio OCR propio", "/app/ocr"},
{"Whisper ASR", "Transcripción de audio vía servicio propio de reconocimiento de voz (self-hosted)", "/app/whisper-asr"},
}
var insertados []models.Submodules
@@ -465,18 +618,15 @@ func SeedIntegraciones() {
}
// 3. Asignar a todos los roles existentes
var roles []models.Roles
if err := db.Find(&roles).Error; err != nil {
log.Printf("[SEED] Error obteniendo roles: %v", err)
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Submódulos de Integraciones asignados al rol '%s'", rol.Name)
}
}
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Println("[SEED] Seed de Integraciones completado.")
}
@@ -519,18 +669,15 @@ func SeedPasarelas() {
insertados = append(insertados, sub)
}
var roles []models.Roles
if err := db.Find(&roles).Error; err != nil {
log.Printf("[SEED] Error obteniendo roles: %v", err)
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Submódulo Pasarelas asignado al rol '%s'", rol.Name)
}
}
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Println("[SEED] Seed de Pasarelas completado.")
}
@@ -596,18 +743,15 @@ func SeedServidores() {
}
// 3. Asignar a todos los roles existentes
var roles []models.Roles
if err := db.Find(&roles).Error; err != nil {
log.Printf("[SEED] Error obteniendo roles: %v", err)
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Submódulos de Servidores asignados al rol '%s'", rol.Name)
}
}
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Println("[SEED] Seed de Servidores completado.")
}
@@ -667,18 +811,15 @@ func SeedAdministracion() {
insertados = append(insertados, sub)
}
var roles []models.Roles
if err := db.Find(&roles).Error; err != nil {
log.Printf("[SEED] Error obteniendo roles: %v", err)
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Submódulos de Administración asignados al rol '%s'", rol.Name)
}
}
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Println("[SEED] Seed de Administración completado.")
}
@@ -722,18 +863,15 @@ func SeedSaas() {
insertados = append(insertados, sub)
}
var roles []models.Roles
if err := db.Find(&roles).Error; err != nil {
log.Printf("[SEED] Error obteniendo roles: %v", err)
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando submódulos SaaS al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Submódulos SaaS asignados al rol '%s'", rol.Name)
}
}
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Println("[SEED] Seed de SaaS completado.")
}
@@ -799,13 +937,15 @@ func SeedTelegram() {
log.Printf("[SEED] Submódulo 'Telegram' ya existe (ID %d)", sub.ID)
}
var roles []models.Roles
if err := db.Find(&roles).Error; err != nil {
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
for _, rol := range roles {
db.Model(&rol).Association("Submodules").Append(&sub)
}
asignarSubmodulosSiFaltan(&rolAdmin, []models.Submodules{sub})
log.Println("[SEED] Seed de Telegram completado.")
}
@@ -857,13 +997,15 @@ func SeedPortalClientes() {
insertados = append(insertados, sub)
}
var roles []models.Roles
if err := db.Find(&roles).Error; err != nil {
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
for _, rol := range roles {
db.Model(&rol).Association("Submodules").Append(&insertados)
}
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Println("[SEED] Seed de Portal de Clientes completado.")
}
@@ -882,7 +1024,7 @@ func SeedNotifDefaults() {
{Evento: "servidor_vence_pronto", Destinatario: "admin", CanalEmail: true, CanalTelegram: true, CanalSistema: true, Descripcion: "Admin recibe cuando un VPS está próximo a vencer"},
{Evento: "servidor_recurso_alto", Destinatario: "admin", CanalEmail: false, CanalTelegram: true, CanalSistema: true, Descripcion: "Admin recibe cuando CPU/RAM/Disco supera el umbral"},
// Tareas
{Evento: "tarea_asignada", Destinatario: "admin", CanalEmail: true, CanalTelegram: false, CanalSistema: true, Descripcion: "Notifica cuando se asigna una tarea a un usuario"},
{Evento: "tarea_asignada", Destinatario: "admin", CanalEmail: true, CanalTelegram: true, CanalSistema: true, Descripcion: "Notifica cuando se asigna una tarea a un usuario"},
{Evento: "tarea_estado", Destinatario: "admin", CanalEmail: false, CanalTelegram: false, CanalSistema: true, Descripcion: "Notifica cuando cambia el estado de una tarea"},
{Evento: "tarea_comentario", Destinatario: "admin", CanalEmail: false, CanalTelegram: false, CanalSistema: true, Descripcion: "Notifica cuando hay un nuevo comentario en una tarea"},
}
@@ -895,6 +1037,17 @@ func SeedNotifDefaults() {
}
}
log.Printf("[SEED] SeedNotifDefaults: %d nuevas configuraciones creadas.", created)
// Corrección puntual (una sola vez): tarea_asignada/admin se sembró originalmente
// con canal_telegram=false, dejando la notificación muerta desde su creación.
// Si nadie tocó el registro desde entonces, se activa por defecto.
var taRow models.NotifEventoConfig
if err := db.Where("evento = ? AND destinatario = ?", "tarea_asignada", "admin").First(&taRow).Error; err == nil {
if !taRow.CanalTelegram && taRow.UpdatedAt.Equal(taRow.CreatedAt) {
db.Model(&taRow).Update("canal_telegram", true)
log.Println("[SEED] tarea_asignada/admin: canal_telegram activado (corrección de default)")
}
}
}
// SeedShield es un stub idempotente para la configuración de Shield.
@@ -935,7 +1088,7 @@ func SeedPartnerRecursos() {
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
db.Model(&rolAdmin).Association("Submodules").Append(&sub)
asignarSubmodulosSiFaltan(&rolAdmin, []models.Submodules{sub})
log.Printf("[SEED] Submódulo 'Partner Recursos' asignado al rol 'Administrador'")
}
log.Println("[SEED] Seed de Partner Recursos completado.")
@@ -989,7 +1142,7 @@ func SeedContabilidadMenu() {
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
db.Model(&rolAdmin).Association("Submodules").Append(&insertados)
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Printf("[SEED] Submódulos de Contabilidad asignados al rol 'Administrador'")
}
log.Println("[SEED] Seed de Contabilidad completado.")
@@ -1026,7 +1179,7 @@ func SeedWebSms() {
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
db.Model(&rolAdmin).Association("Submodules").Append(&[]models.Submodules{sub})
asignarSubmodulosSiFaltan(&rolAdmin, []models.Submodules{sub})
log.Printf("[SEED] Submódulo 'WebSMS' asignado al rol 'Administrador'")
}
log.Println("[SEED] Seed de WebSMS completado.")
@@ -1062,7 +1215,7 @@ func SeedUrlMonitor() {
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
db.Model(&rol).Association("Submodules").Append(&[]models.Submodules{sub})
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
log.Printf("[SEED] Submódulo 'Monitor de URLs' asignado al rol 'Administrador'")
}
log.Println("[SEED] Seed de Monitor de URLs completado.")
@@ -1098,8 +1251,408 @@ func SeedTareas() {
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
db.Model(&rol).Association("Submodules").Append(&[]models.Submodules{sub})
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
log.Printf("[SEED] Submódulo 'Tareas' asignado al rol 'Administrador'")
}
log.Println("[SEED] Seed de Tareas completado.")
}
// SeedPagosExternos registra el submódulo del panel de administración de
// servicios de pago externos (emisión de tokens de API para terceros).
func SeedPagosExternos() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Administración").First(&modulo).Error; err != nil {
log.Println("[SEED] Módulo 'Administración' no encontrado, se omite SeedPagosExternos")
return
}
url := "/app/pagos-externos"
var sub models.Submodules
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: "Pagos externos",
Description: "Tokens de API para que apps de terceros pidan cobros por Bold/dLocal/PayPal",
Url: url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo 'Pagos externos': %v", err)
return
}
log.Printf("[SEED] Submódulo 'Pagos externos' creado")
} else if sub.ModuleId != modulo.ID {
db.Model(&sub).Update("module_id", modulo.ID)
}
var rol models.Roles
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
log.Printf("[SEED] Submódulo 'Pagos externos' asignado al rol 'Administrador'")
}
log.Println("[SEED] Seed de Pagos externos completado.")
}
// SeedUmind registra el submódulo del panel de administración de uMind
// (tenants, base de conocimiento y conversaciones del widget embebible).
func SeedUmind() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Automatización IA").First(&modulo).Error; err != nil {
log.Println("[SEED] Módulo 'Automatización IA' no encontrado, se omite SeedUmind")
return
}
// El panel viejo (Alpine, /app/umind) sigue existiendo y respondiendo, pero
// el menú apunta al SPA. La URL pasó por /app/umind → /orchestrator →
// /studio: se busca por cualquiera de las tres y se migra el mismo
// registro, en vez de dejar submódulos duplicados en el menú.
url := "/studio"
var sub models.Submodules
if err := db.Where("url IN ?", []string{url, "/orchestrator", "/app/umind"}).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: "uMind Studio",
Description: "Creación y gestión de agentes de IA: conocimiento, canales, herramientas y consumo",
Url: url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo 'uMind': %v", err)
return
}
log.Printf("[SEED] Submódulo 'uMind' creado")
} else {
updates := map[string]interface{}{}
if sub.ModuleId != modulo.ID {
updates["module_id"] = modulo.ID
}
if sub.Url != url {
updates["url"] = url
}
if len(updates) > 0 {
db.Model(&sub).Updates(updates)
}
}
// Planes de uMind: límite de agentes y precios por consumo. Solo staff.
var subPlanes models.Submodules
if err := db.Where("url = ?", "/app/umind-planes").First(&subPlanes).Error; err != nil {
subPlanes = models.Submodules{
Title: "Planes uMind",
Description: "Límite de agentes y precios por consumo (IA, OCR, transcripción, mensualidad)",
Url: "/app/umind-planes",
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&subPlanes).Error; err != nil {
log.Printf("[SEED] Error creando submódulo 'Planes uMind': %v", err)
} else {
log.Printf("[SEED] Submódulo 'Planes uMind' creado")
}
}
var rol models.Roles
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
subs := []models.Submodules{sub}
if subPlanes.ID > 0 {
subs = append(subs, subPlanes)
}
asignarSubmodulosSiFaltan(&rol, subs)
log.Printf("[SEED] Submódulos de uMind asignados al rol 'Administrador'")
}
log.Println("[SEED] Seed de uMind completado.")
}
// SeedApiKeys registra el submódulo del panel de administración de API Keys
// (credenciales scoped para /api/v2, alternativa al ADMIN_API_KEY maestro).
func SeedApiKeys() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Administración").First(&modulo).Error; err != nil {
log.Println("[SEED] Módulo 'Administración' no encontrado, se omite SeedApiKeys")
return
}
url := "/app/api-keys"
var sub models.Submodules
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: "API Keys",
Description: "Credenciales scoped para /api/v2 (token + IP + alcance), alternativa a la llave maestra",
Url: url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo 'API Keys': %v", err)
return
}
log.Printf("[SEED] Submódulo 'API Keys' creado")
} else if sub.ModuleId != modulo.ID {
db.Model(&sub).Update("module_id", modulo.ID)
}
var rol models.Roles
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
log.Printf("[SEED] Submódulo 'API Keys' asignado al rol 'Administrador'")
}
log.Println("[SEED] Seed de API Keys completado.")
}
// MigrarUmindAgentes crea un agente "Principal" por cada UmindTenant que
// todavía no tenga ninguno, heredando lo que antes vivía en el tenant
// (site_key, config de IA, tono, mensaje de bienvenida, color) y mueve los
// datos que ya tenía (documentos, chunks, tools, canales, conexiones,
// mensajes) de tenant_id a agente_id. Idempotente: un tenant que ya tiene
// al menos un agente se salta — así corre sola en cada arranque sin
// duplicar nada, mismo criterio que los Seed* de este archivo.
func MigrarUmindAgentes() {
db := app.Http.Database.DB
var tenants []models.UmindTenant
if err := db.Find(&tenants).Error; err != nil {
log.Printf("[MIGRACION] Error leyendo umind_tenants: %v", err)
return
}
tablasConAgenteID := []string{
"umind_documentos", "umind_chunks", "umind_herramientas",
"umind_canales", "umind_conexiones", "umind_mensajes",
}
for _, t := range tenants {
var yaTieneAgente int64
if err := db.Model(&models.UmindAgente{}).Where("tenant_id = ?", t.ID).Count(&yaTieneAgente).Error; err != nil {
log.Printf("[MIGRACION] Error contando agentes del tenant %d: %v", t.ID, err)
continue
}
if yaTieneAgente > 0 {
continue
}
// Las columnas viejas (site_key, ai_config_id, tono,
// mensaje_bienvenida, color) ya no están en el struct Go UmindTenant,
// pero AutoMigrate nunca las borró de la tabla — se leen directo.
var viejo struct {
SiteKey string
AiConfigID *uint
Tono string
MensajeBienvenida string
Color string
}
if err := db.Table("umind_tenants").
Select("site_key, ai_config_id, tono, mensaje_bienvenida, color").
Where("id = ?", t.ID).Scan(&viejo).Error; err != nil {
log.Printf("[MIGRACION] Error leyendo datos viejos del tenant %d: %v", t.ID, err)
continue
}
agente := models.UmindAgente{
TenantID: t.ID,
Nombre: "Principal",
SiteKey: viejo.SiteKey,
AiConfigID: viejo.AiConfigID,
Tono: viejo.Tono,
MensajeBienvenida: viejo.MensajeBienvenida,
Color: viejo.Color,
Activo: true,
}
if agente.SiteKey == "" {
key, err := models.GenerarSiteKey()
if err != nil {
log.Printf("[MIGRACION] Error generando site_key para el agente del tenant %d: %v", t.ID, err)
continue
}
agente.SiteKey = key
}
if agente.Color == "" {
agente.Color = "#8eb02f"
}
if err := db.Create(&agente).Error; err != nil {
log.Printf("[MIGRACION] Error creando agente Principal del tenant %d: %v", t.ID, err)
continue
}
for _, tabla := range tablasConAgenteID {
sql := fmt.Sprintf("UPDATE %s SET agente_id = ? WHERE tenant_id = ? AND (agente_id IS NULL OR agente_id = 0)", tabla)
if err := db.Exec(sql, agente.ID, t.ID).Error; err != nil {
log.Printf("[MIGRACION] Error moviendo %s del tenant %d al agente %d: %v", tabla, t.ID, agente.ID, err)
}
}
log.Printf("[MIGRACION] Tenant %d (%s): agente 'Principal' creado (id=%d), datos migrados", t.ID, t.Nombre, agente.ID)
}
}
// LiberarColumnasHuerfanasUmind quita el NOT NULL de las columnas que dejaron
// de mapearse cuando uMind pasó a multi-agente.
//
// GORM agrega columnas pero nunca las borra ni les cambia las restricciones.
// Al sacar SiteKey de UmindTenant y renombrar TenantID→AgenteID en seis
// tablas, las columnas viejas quedaron en la base CON su NOT NULL original.
// El INSERT nuevo ya no las incluye, así que Postgres lo rechaza:
//
// null value in column "site_key" violates not-null constraint (23502)
//
// Sin esto no se puede crear un tenant, ni un documento, ni un chunk, ni
// guardar un mensaje: es decir, uMind queda inutilizable después de
// desplegar el refactor.
//
// No se hace DROP COLUMN a propósito: los datos viejos siguen ahí por si hay
// que reconciliar algo. Solo se libera la restricción.
// ColumnasHuerfanasUmind es la lista que recorre LiberarColumnasHuerfanasUmind.
// Está afuera de la función para poder verificar en un test que cada nombre de
// tabla coincide con el TableName() real del modelo: un typo acá haría que la
// migración no encuentre la columna y no haga nada, en silencio.
var ColumnasHuerfanasUmind = []struct{ Tabla, Columna string }{
{"umind_tenants", "site_key"},
{"umind_documentos", "tenant_id"},
{"umind_chunks", "tenant_id"},
{"umind_mensajes", "tenant_id"},
{"umind_herramientas", "tenant_id"},
{"umind_canales", "tenant_id"},
{"umind_conexiones", "tenant_id"},
}
func LiberarColumnasHuerfanasUmind() {
db := app.Http.Database.DB
for _, h := range ColumnasHuerfanasUmind {
// Se consulta information_schema en vez de intentar el ALTER a ciegas:
// en una instalación nueva la columna no existe y el error sería ruido
// en cada arranque.
var nullable string
err := db.Raw(`
SELECT is_nullable FROM information_schema.columns
WHERE table_name = ? AND column_name = ?
`, h.Tabla, h.Columna).Scan(&nullable).Error
if err != nil || nullable == "" || nullable == "YES" {
continue
}
sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP NOT NULL", h.Tabla, h.Columna)
if err := db.Exec(sql).Error; err != nil {
log.Printf("[MIGRACION] no se pudo liberar %s.%s: %v", h.Tabla, h.Columna, err)
continue
}
log.Printf("[MIGRACION] %s.%s ya no es NOT NULL (columna huérfana del refactor multi-agente)", h.Tabla, h.Columna)
}
}
// VerificarUrlsDeSubmodulos avisa en el arranque de los ítems del menú cuya
// URL no corresponde a ninguna ruta registrada.
//
// Un submódulo es una URL escrita a mano (en un seed o desde /app/submodules).
// Si no coincide con una ruta real, el ítem igual aparece en el menú y se le
// puede asignar permiso a un rol — y recién al hacer clic aparece un 404, sin
// nada que explique por qué. Esto lo convierte en una línea de log al
// arrancar, en vez de un misterio en producción.
//
// Solo informa: no borra ni modifica nada, porque una URL "rota" puede ser una
// ruta servida por otro lado.
func VerificarUrlsDeSubmodulos(rutasGET map[string]bool) {
var subs []models.Submodules
if err := app.Http.Database.DB.Find(&subs).Error; err != nil {
log.Printf("[MENU] no se pudieron revisar las URLs de los submódulos: %v", err)
return
}
rotos := 0
for _, s := range subs {
url := strings.TrimSpace(s.Url)
if url == "" {
log.Printf("[MENU] el submódulo %q (ID %d) no tiene URL", s.Title, s.ID)
rotos++
continue
}
if !rutasGET[url] {
log.Printf("[MENU] el submódulo %q (ID %d) apunta a %q, que no es ninguna ruta: va a dar 404", s.Title, s.ID, url)
rotos++
}
}
if rotos == 0 {
log.Printf("[MENU] %d submódulos revisados, todas las URLs resuelven", len(subs))
} else {
log.Printf("[MENU] %d de %d submódulos apuntan a una URL inexistente (ver líneas anteriores)", rotos, len(subs))
}
}
// SeedDocumentacion registra los submódulos de Documentación.
//
// Existían las rutas y las vistas pero nunca el seed, así que había que crear
// el ítem del menú a mano desde /app/submodules — y una URL mal tipeada ahí es
// indistinguible de un permiso mal asignado: en los dos casos se ve un 404.
func SeedDocumentacion() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Documentación").First(&modulo).Error; err != nil {
modulo = models.Modules{
Title: "Documentación",
Description: "Páginas y categorías de documentación interna",
ModifiedAt: time.Now(),
}
if err := db.Create(&modulo).Error; err != nil {
log.Printf("[SEED] Error creando módulo Documentación: %v", err)
return
}
}
entries := []struct{ title, desc, url string }{
{"Doc: Páginas", "Páginas de documentación", "/app/doc/paginas"},
{"Doc: Categorías", "Categorías de documentación", "/app/doc/categorias"},
}
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
}
}
}
// IndicesUnicosMessageID crea índices únicos parciales sobre el Message-Id de
// los correos ya procesados. Es lo que hace que un mismo correo no pueda abrir
// dos tickets pase lo que pase: si el buzón vuelve a entregarlo, si el flag de
// leído no se guardó, o si dos instancias de la app lo leen a la vez, la base
// rechaza el segundo.
//
// Parcial (WHERE message_id <> ”) porque los tickets del portal no tienen
// Message-Id y todos comparten la cadena vacía.
func IndicesUnicosMessageID() {
db := app.Http.Database.DB
indices := []struct{ nombre, tabla string }{
{"idx_tickets_message_id_unico", "proyecto_tickets"},
{"idx_ticket_mensajes_message_id_unico", "ticket_mensajes"},
}
for _, ix := range indices {
var existe bool
if err := db.Raw(
`SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = ?)`, ix.nombre,
).Scan(&existe).Error; err != nil || existe {
continue
}
// Si ya hay duplicados de antes, el índice no se puede crear. Se avisa y
// se sigue: el chequeo previo en código igual filtra la mayoría.
sql := fmt.Sprintf(
`CREATE UNIQUE INDEX %s ON %s (message_id) WHERE message_id <> '' AND deleted_at IS NULL`,
ix.nombre, ix.tabla)
if err := db.Exec(sql).Error; err != nil {
log.Printf("[MIGRATE] No se pudo crear %s (¿hay correos duplicados de antes?): %v", ix.nombre, err)
continue
}
log.Printf("[MIGRATE] Índice %s creado.", ix.nombre)
}
}
+53
View File
@@ -0,0 +1,53 @@
package migrations
import (
"testing"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// El refactor multi-agente dejó columnas viejas con su NOT NULL original, y
// GORM no las toca. El síntoma en producción es:
//
// null value in column "site_key" violates not-null constraint (23502)
//
// Si un nombre de tabla acá no coincide con el TableName() real, la migración
// consulta information_schema, no encuentra nada y sigue de largo sin avisar:
// el bug quedaría igual y el arranque se vería sano. Por eso se comparan
// contra los modelos en vez de confiar en las cadenas escritas a mano.
func TestTablasHuerfanasCoincidenConLosModelos(t *testing.T) {
reales := map[string]bool{
models.UmindTenant{}.TableName(): true,
models.UmindDocumento{}.TableName(): true,
models.UmindChunk{}.TableName(): true,
models.UmindMensaje{}.TableName(): true,
models.UmindHerramienta{}.TableName(): true,
models.UmindCanal{}.TableName(): true,
models.UmindConexion{}.TableName(): true,
}
for _, h := range ColumnasHuerfanasUmind {
if !reales[h.Tabla] {
t.Errorf("la tabla %q no corresponde a ningún TableName() de uMind — la migración no encontraría la columna", h.Tabla)
}
}
// Las siete tablas afectadas tienen que estar cubiertas: si falta una,
// insertar en ella sigue fallando.
if len(ColumnasHuerfanasUmind) != len(reales) {
t.Errorf("hay %d entradas para %d tablas afectadas", len(ColumnasHuerfanasUmind), len(reales))
}
}
// Las columnas huérfanas ya no deben existir en los structs: si alguna volvió
// a mapearse, liberar su NOT NULL sería incorrecto.
func TestLasColumnasHuerfanasYaNoSeMapean(t *testing.T) {
// UmindTenant ya no debe tener SiteKey; vive en UmindAgente.
if _, tiene := any(models.UmindAgente{}).(interface{ TableName() string }); !tiene {
t.Skip("modelo inesperado")
}
a := models.UmindAgente{SiteKey: "umk_x"}
if a.SiteKey != "umk_x" {
t.Error("UmindAgente debería ser el dueño de SiteKey")
}
}
+1
View File
@@ -0,0 +1 @@
node_modules/
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
<title>uMind Studio</title>
</head>
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
que son los que cambian con el tema. Una utilidad acá le ganaba a la
capa base y dejaba el fondo claro también en modo oscuro. -->
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
{
"name": "umind-orchestrator",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.7"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+39
View File
@@ -0,0 +1,39 @@
<script setup>
import Sidebar from './components/Sidebar.vue'
import { tema, alternarTema } from './lib/tema.js'
import { menuAbierto } from './lib/ui.js'
import DespertarUmind from './components/DespertarUmind.vue'
import UiIcono from './components/ui/UiIcono.vue'
</script>
<template>
<div class="min-h-screen flex">
<DespertarUmind />
<Sidebar />
<main class="flex-1 min-w-0 flex flex-col">
<header class="h-14 shrink-0 flex items-center gap-1 px-4 sm:px-6 border-b border-borde">
<!-- Solo en móvil: en escritorio el sidebar está siempre visible. -->
<button
class="btn-ghost !px-2 !py-1.5 md:hidden"
aria-label="Abrir menú"
@click="menuAbierto = true"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span class="flex-1"></span>
<button
class="btn-ghost !px-2.5 !py-1.5"
:title="tema === 'oscuro' ? 'Cambiar a claro' : 'Cambiar a oscuro'"
@click="alternarTema"
>
<UiIcono :nombre="tema === 'oscuro' ? 'sol' : 'luna'" :tam="17" />
</button>
</header>
<div class="flex-1 max-w-5xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8 animate-aparecer">
<router-view />
</div>
</main>
</div>
</template>
@@ -0,0 +1,117 @@
<script setup>
import { onMounted, onBeforeUnmount, ref } from 'vue'
// El campo de puntos conectándose, una sola vez al entrar. Es el mismo lenguaje
// visual de la página pública —puntos que se enlazan, como el espacio vectorial
// donde vive el conocimiento del agente— así que refuerza la marca en vez de
// inventar algo nuevo para adentro.
//
// Dura poco más de un segundo y se destruye. Un fondo animado permanente en una
// herramienta de trabajo se ve bien diez segundos y molesta las ocho horas
// siguientes, además de tener un canvas comiendo batería todo el día.
const CLAVE = 'umind_despertar_visto'
const DURACION = 1250
const lienzo = ref(null)
const visible = ref(false)
let raf = null
let apagar = null
function quieto() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
onMounted(() => {
// Una vez por sesión: verlo en cada navegación del SPA sería exactamente el
// fondo permanente que se quiere evitar.
if (quieto() || sessionStorage.getItem(CLAVE)) return
sessionStorage.setItem(CLAVE, '1')
visible.value = true
// El apagado se programa ACÁ y no dentro del requestAnimationFrame: en una
// pestaña de fondo el navegador no corre rAF, así que el timeout nunca
// llegaba a programarse y el canvas quedaba puesto para siempre. Abrir el
// Studio en una pestaña que no se está mirando alcanzaba para dejarlo tapado
// con una capa invisible.
apagar = setTimeout(() => (visible.value = false), DURACION)
requestAnimationFrame(() => {
const cv = lienzo.value
if (!cv) return
const ctx = cv.getContext('2d')
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const w = window.innerWidth
const h = window.innerHeight
cv.width = Math.round(w * dpr)
cv.height = Math.round(h * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
// Menos puntos en pantallas chicas: en un teléfono la misma densidad se ve
// sucia y cuesta más de dibujar.
const cantidad = w < 640 ? 22 : 46
const puntos = Array.from({ length: cantidad }, () => ({
x: Math.random() * w,
y: Math.random() * h,
r: 1 + Math.random() * 1.6,
demora: Math.random() * 0.45,
}))
const inicio = performance.now()
const pintar = (ahora) => {
const t = Math.min(1, (ahora - inicio) / DURACION)
ctx.clearRect(0, 0, w, h)
// Entra y sale en la misma curva: sin el desvanecido final el corte se
// nota como un parpadeo.
const opacidadGlobal = t < 0.75 ? 1 : 1 - (t - 0.75) / 0.25
for (let i = 0; i < puntos.length; i++) {
const p = puntos[i]
const avance = Math.max(0, Math.min(1, (t - p.demora) / 0.4))
if (avance <= 0) continue
for (let j = i + 1; j < puntos.length; j++) {
const q = puntos[j]
const dx = p.x - q.x
const dy = p.y - q.y
const d = Math.sqrt(dx * dx + dy * dy)
if (d > 170) continue
ctx.strokeStyle = `rgba(142, 176, 47, ${0.16 * (1 - d / 170) * avance * opacidadGlobal})`
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(p.x, p.y)
ctx.lineTo(q.x, q.y)
ctx.stroke()
}
ctx.beginPath()
ctx.arc(p.x, p.y, p.r * avance, 0, Math.PI * 2)
ctx.fillStyle = `rgba(142, 176, 47, ${0.5 * avance * opacidadGlobal})`
ctx.fill()
}
if (t < 1) raf = requestAnimationFrame(pintar)
}
raf = requestAnimationFrame(pintar)
})
})
// Si alguien navega antes de que termine, no queda un rAF girando ni un
// timeout tocando un componente que ya no existe.
onBeforeUnmount(() => {
if (raf) cancelAnimationFrame(raf)
if (apagar) clearTimeout(apagar)
})
</script>
<template>
<!-- pointer-events-none: es decorativo y no puede robarle un click a nada. -->
<canvas
v-if="visible"
ref="lienzo"
class="fixed inset-0 w-full h-full pointer-events-none z-50"
aria-hidden="true"
></canvas>
</template>
+285
View File
@@ -0,0 +1,285 @@
<script setup>
import UiIcono from './ui/UiIcono.vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.js'
import { menuAbierto } from '../lib/ui.js'
const route = useRoute()
const router = useRouter()
const tenants = ref([])
const clientes = ref([])
const planes = ref([])
const loading = ref(true)
const error = ref('')
const showForm = ref(false)
const editing = ref(null)
const form = ref(vacio())
// El tenant "activo" en la nav es tanto /tenants/:id como cualquier ruta
// anidada de sus agentes (/tenants/:tenantId/agentes/:agenteId).
const tenantActivoId = computed(() => route.params.tenantId || route.params.id)
// Al elegir un tenant en móvil el menú se cierra solo: dejarlo abierto tapa
// justo la pantalla a la que se acaba de entrar.
watch(() => route.fullPath, () => { menuAbierto.value = false })
function vacio() {
return { nombre: '', dominios_permitidos: '', activo: true, cliente_id: null, plan_id: null }
}
async function cargar() {
loading.value = true
error.value = ''
try {
const t = await api.get(apiUmind('/umind/tenants'))
tenants.value = t.items || []
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
// Los endpoints del panel no comparten una forma de respuesta: el de clientes
// devuelve el array pelado y el de planes lo envuelve en {items}. Aceptar las
// dos evita que un select quede vacío sin que nadie se entere.
function lista(r) {
if (Array.isArray(r)) return r
return r?.items || r?.registros || []
}
// Clientes y planes solo los necesita el staff para asignarlos; en el portal
// del cliente esos endpoints no se alcanzan y el formulario no los muestra.
// Se cargan por separado a propósito: que falle uno no debe vaciar el otro.
async function cargarAsignables() {
if (contexto.esPortal) return
const [c, p] = await Promise.allSettled([
api.get('/app/api/clientes/select'),
api.get('/app/umind-planes/list'),
])
clientes.value = c.status === 'fulfilled' ? lista(c.value) : []
planes.value = p.status === 'fulfilled' ? lista(p.value) : []
// Sin esto un fallo se veía igual que "no hay nada configurado", que es
// justo lo que hizo que los campos Cliente y Plan no aparecieran.
const fallos = []
if (c.status === 'rejected') fallos.push('clientes')
if (p.status === 'rejected') fallos.push('planes')
if (fallos.length) error.value = `No se pudo cargar la lista de ${fallos.join(' ni ')}.`
}
function nuevoTenant() {
editing.value = null
form.value = vacio()
showForm.value = true
}
function editarTenant(t) {
editing.value = t
form.value = {
nombre: t.nombre,
dominios_permitidos: t.dominios_permitidos,
activo: t.activo,
cliente_id: t.cliente_id ?? null,
plan_id: t.plan_id ?? null,
}
showForm.value = true
}
async function guardar() {
const payload = {
...form.value,
dominios_permitidos: form.value.dominios_permitidos
.split(',')
.map((d) => d.trim())
.filter(Boolean),
}
try {
if (editing.value) {
await api.put(apiUmind(`/umind/tenants/${editing.value.ID}`), payload)
showForm.value = false
await cargar()
} else {
const r = await api.post(apiUmind('/umind/tenants'), payload)
showForm.value = false
await cargar()
router.push(`/tenants/${r.id}`)
}
} catch (e) {
error.value = e.message
}
}
async function eliminar(t) {
if (!confirm(`¿Eliminar el espacio "${t.nombre}"? Esto borra también sus agentes. No se puede deshacer.`)) return
await api.del(apiUmind(`/umind/tenants/${t.ID}`))
if (tenantActivoId.value === String(t.ID)) router.push('/')
await cargar()
}
defineExpose({ recargar: cargar })
onMounted(() => {
cargar()
cargarAsignables()
})
</script>
<template>
<!-- Fondo oscuro en móvil: sin esto el menú abierto se superpone al
contenido sin dejar claro que hay que cerrarlo. -->
<div
v-if="menuAbierto"
class="fixed inset-0 bg-black/50 z-30 md:hidden"
@click="menuAbierto = false"
></div>
<aside
class="w-64 shrink-0 flex flex-col border-r border-borde bg-superficie
fixed inset-y-0 left-0 z-40 transition-transform duration-200
md:static md:h-screen md:sticky md:top-0 md:translate-x-0"
:class="menuAbierto ? 'translate-x-0' : '-translate-x-full'"
>
<div class="h-14 px-4 flex items-center gap-2 border-b border-borde">
<router-link to="/" class="flex items-center gap-2 text-base font-semibold text-texto">
<svg viewBox="0 0 96 96" class="w-6 h-6 shrink-0" aria-hidden="true">
<rect width="96" height="96" rx="22" fill="#8eb02f" />
<path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" />
<path d="M60,58 V64" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" />
<circle cx="60" cy="28" r="7" fill="#fff" />
</svg>
<span>uMind <span class="text-brand">Studio</span></span>
</router-link>
</div>
<div v-if="!contexto.esPortal" class="px-3 pt-3">
<button
class="btn-primary w-full"
@click="nuevoTenant"
>
+ Nuevo espacio
</button>
</div>
<p v-if="error" class="px-3 pt-2 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5">
<p v-if="loading" class="px-2 text-xs text-tenue">Cargando...</p>
<p v-else-if="tenants.length === 0" class="px-2 text-xs text-tenue">
{{ contexto.esPortal ? 'Todavía no tenés ningún espacio asignado. Escribinos y lo activamos.' : 'Sin espacios todavía.' }}
</p>
<div
v-for="t in tenants"
:key="t.ID"
class="group flex items-center rounded-lg transition-colors"
:class="tenantActivoId === String(t.ID) ? 'bg-brand/10' : 'hover:bg-elevado'"
>
<router-link
:to="`/tenants/${t.ID}`"
class="flex-1 min-w-0 px-2.5 py-2 text-sm"
:class="tenantActivoId === String(t.ID) ? 'text-brand font-medium' : 'text-texto'"
>
<div class="truncate">{{ t.nombre }}</div>
<div class="flex items-center gap-1 mt-0.5">
<span class="w-1.5 h-1.5 rounded-full" :class="t.activo ? 'bg-green-500' : 'bg-tenue/40'"></span>
<span class="text-[11px] text-tenue">{{ t.activo ? 'activo' : 'inactivo' }}</span>
</div>
</router-link>
<div v-if="!contexto.esPortal" class="flex opacity-0 group-hover:opacity-100 transition-opacity pr-1.5 gap-0.5">
<button
class="p-1 text-tenue hover:text-texto"
title="Editar"
@click="editarTenant(t)"
>
<UiIcono nombre="lapiz" :tam="14" />
</button>
<button
class="p-1 text-tenue hover:text-red-600"
title="Eliminar"
@click="eliminar(t)"
>
<UiIcono nombre="cerrar" :tam="14" />
</button>
</div>
</div>
</nav>
</aside>
<!-- Modal de alta/edición -->
<div
v-if="showForm"
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
@click.self="showForm = false"
>
<div class="card w-full max-w-lg p-6 animate-escalar shadow-2xl">
<h2 class="font-semibold text-texto mb-4">
{{ editing ? 'Editar espacio' : 'Nuevo espacio' }}
</h2>
<p class="text-xs text-tenue mb-3">
Un espacio es el negocio o sitio dueño de los dominios permitidos. La config de IA, tono y demás se configuran por agente, dentro del tenant.
</p>
<form class="space-y-3" @submit.prevent="guardar">
<div>
<label class="label">Nombre</label>
<input
v-model="form.nombre"
required
class="input"
/>
</div>
<div>
<label class="label">Dominios permitidos (separados por coma)</label>
<input
v-model="form.dominios_permitidos"
placeholder="ejemplo.com, www.ejemplo.com"
required
class="input"
/>
</div>
<div v-if="!contexto.esPortal" class="grid grid-cols-2 gap-3">
<div>
<label class="label">Cliente</label>
<select
v-model="form.cliente_id"
class="input"
>
<option :value="null"> sin asignar </option>
<option v-for="c in clientes" :key="c.ID" :value="c.ID">{{ c.nombre }}</option>
</select>
<p class="text-[11px] text-tenue mt-1">
{{ clientes.length ? 'Define quién ve este espacio desde el portal.' : 'No hay clientes activos — creá uno en Clientes.' }}
</p>
</div>
<div>
<label class="label">Plan</label>
<select
v-model="form.plan_id"
class="input"
>
<option :value="null"> sin plan </option>
<option v-for="p in planes" :key="p.ID" :value="p.ID">
{{ p.nombre }} ({{ p.max_agentes === 0 ? '' : p.max_agentes }} agentes)
</option>
</select>
<p class="text-[11px] text-tenue mt-1">
{{ planes.length ? 'Límite de agentes y precios de consumo.' : 'No hay planes — creá uno en uMind Planes.' }}
</p>
</div>
</div>
<label class="flex items-center gap-2 text-sm text-texto">
<input v-model="form.activo" type="checkbox" />
Activo
</label>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="btn-ghost" @click="showForm = false">
Cancelar
</button>
<button type="submit" class="btn-primary">
Guardar
</button>
</div>
</form>
</div>
</div>
</template>
@@ -0,0 +1,16 @@
<script setup>
// tipo: ok | alerta | error | neutro
defineProps({ tipo: { type: String, default: 'neutro' }, punto: Boolean })
const clases = { ok: 'badge-ok', alerta: 'badge-alerta', error: 'badge-error', neutro: 'badge-neutro' }
</script>
<template>
<span :class="clases[tipo] || 'badge-neutro'">
<span
v-if="punto"
class="w-1.5 h-1.5 rounded-full"
:class="{ ok: 'bg-green-500', alerta: 'bg-amber-500', error: 'bg-red-500', neutro: 'bg-gray-400' }[tipo]"
></span>
<slot />
</span>
</template>
@@ -0,0 +1,30 @@
<script setup>
import UiMascota from './UiMascota.vue'
// Una pantalla vacía es la primera que ve alguien que recién empieza, y la que
// más veces ve el que todavía no configuró algo. Decir "Sin canales" y nada
// más deja al usuario resolviendo solo qué significa eso y qué hacer.
//
// La mascota lleva el estado: dormida cuando no pasó nada todavía, buscando
// cuando falta cargar información, alerta cuando algo se rompió. Se lee antes
// que el texto.
defineProps({
// Estado de Umi. Sin él, cae en 'durmiendo', que es el caso más común.
estado: { type: String, default: 'durmiendo' },
titulo: String,
detalle: String,
// Escape para los pocos casos donde un emoji dice más que la mascota.
icono: { type: String, default: '' },
})
</script>
<template>
<div class="flex flex-col items-center justify-center text-center py-12 px-6">
<div v-if="icono" class="text-3xl mb-3 opacity-70">{{ icono }}</div>
<UiMascota v-else :estado="estado" :tam="64" class="mb-3 text-brand" />
<p class="text-sm font-medium text-texto">{{ titulo }}</p>
<p v-if="detalle" class="text-xs text-tenue mt-1.5 max-w-sm leading-relaxed">{{ detalle }}</p>
<div v-if="$slots.default" class="mt-4"><slot /></div>
</div>
</template>
+111
View File
@@ -0,0 +1,111 @@
<script setup>
// Un solo set de iconos para todo uMind.
//
// Antes convivían emojis a color (🌙 📊 🧠 ✍️) con símbolos tipográficos
// monocromos (✎ ✕ ← ↻). Dos lenguajes distintos en la misma pantalla se leen
// como descuido, y encima los emojis los dibuja el sistema operativo: el mismo
// panel se ve distinto en Mac, en Windows y en Android, sin que podamos hacer
// nada.
//
// Estos son SVG de trazo que heredan currentColor, así que toman el color del
// texto que los rodea y funcionan igual en tema claro y oscuro. Mismo grosor de
// línea y misma caja en todos: al lado uno de otro tienen el mismo peso visual.
defineProps({
nombre: { type: String, required: true },
tam: { type: [Number, String], default: 16 },
})
// Cada icono es una lista de [etiqueta, atributos]. No es una cadena de HTML a
// propósito: v-html dentro de un <svg> parsea como HTML y los <path> quedan sin
// el namespace de SVG — se insertan en el DOM pero no dibujan nada. Declarados
// así, Vue los crea con el namespace correcto porque ve el <svg> padre.
const ICONOS = {
luna: [['path', { d: 'M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z' }]],
sol: [
['circle', { cx: 12, cy: 12, r: 4 }],
['path', { d: 'M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4' }],
],
lapiz: [
['path', { d: 'M12 20h9' }],
['path', { d: 'M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z' }],
],
cerrar: [['path', { d: 'M18 6 6 18M6 6l12 12' }]],
atras: [['path', { d: 'M19 12H5M12 19l-7-7 7-7' }]],
grafico: [
['path', { d: 'M3 3v18h18' }],
['path', { d: 'M7 15v3M12 10v8M17 6v12' }],
],
cerebro: [
['path', { d: 'M9.5 3A2.5 2.5 0 0 0 7 5.5v.2A2.7 2.7 0 0 0 5 8.3c0 .8.3 1.5.9 2A2.8 2.8 0 0 0 5 12.5c0 1 .5 1.9 1.3 2.4-.2.4-.3.8-.3 1.2A2.9 2.9 0 0 0 9 19a2.5 2.5 0 0 0 3 2V3.8A2.5 2.5 0 0 0 9.5 3Z' }],
['path', { d: 'M14.5 3A2.5 2.5 0 0 1 17 5.5v.2a2.7 2.7 0 0 1 2 2.6c0 .8-.3 1.5-.9 2A2.8 2.8 0 0 1 19 12.5c0 1-.5 1.9-1.3 2.4.2.4.3.8.3 1.2A2.9 2.9 0 0 1 15 19a2.5 2.5 0 0 1-3 2' }],
],
imagen: [
['rect', { x: 3, y: 3, width: 18, height: 18, rx: 2 }],
['circle', { cx: 8.5, cy: 8.5, r: 1.5 }],
['path', { d: 'm21 15-5-5L5 21' }],
],
microfono: [
['rect', { x: 9, y: 2, width: 6, height: 12, rx: 3 }],
['path', { d: 'M5 10a7 7 0 0 0 14 0M12 17v5' }],
],
escribir: [
['path', { d: 'M3 21h18' }],
['path', { d: 'M5 17 17.5 4.5a2.1 2.1 0 0 1 3 3L8 20l-4 1Z' }],
],
archivo: [
['path', { d: 'M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z' }],
['path', { d: 'M14 2v5h5' }],
],
sitio: [
['circle', { cx: 12, cy: 12, r: 9 }],
['path', { d: 'M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18Z' }],
],
refrescar: [
['path', { d: 'M21 12a9 9 0 1 1-3-6.7' }],
['path', { d: 'M21 4v5h-5' }],
],
descargar: [
['path', { d: 'M12 3v12' }],
['path', { d: 'm7 11 5 5 5-5' }],
['path', { d: 'M4 21h16' }],
],
alerta: [
['path', { d: 'M12 9v4M12 17h.01' }],
['path', { d: 'M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z' }],
],
ok: [['path', { d: 'M20 6 9 17l-5-5' }]],
copiar: [
['rect', { x: 9, y: 9, width: 12, height: 12, rx: 2 }],
['path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' }],
],
mensaje: [['path', { d: 'M21 11.5a8.4 8.4 0 0 1-9 8.4 8.5 8.5 0 0 1-3.9-.9L3 21l1.9-5.1A8.4 8.4 0 0 1 4 11.5a8.5 8.5 0 0 1 8.5-8.5A8.4 8.4 0 0 1 21 11.5Z' }]],
auto: [
['path', { d: 'M17 2.1 21 6l-4 3.9' }],
['path', { d: 'M3 11V9a4 4 0 0 1 4-4h14' }],
['path', { d: 'M7 21.9 3 18l4-3.9' }],
['path', { d: 'M21 13v2a4 4 0 0 1-4 4H3' }],
],
}
</script>
<template>
<svg
:width="tam"
:height="tam"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
class="inline-block shrink-0 align-[-0.15em]"
aria-hidden="true"
>
<component
v-for="(el, i) in ICONOS[nombre] || []"
:key="i"
:is="el[0]"
v-bind="el[1]"
/>
</svg>
</template>
@@ -0,0 +1,118 @@
<script setup>
// Umi, la mascota de uMind.
//
// No es un dibujo suelto: sale del logo. El cuerpo es la "u" del monograma y
// el punto del logo pasa a ser su antena, así que el símbolo de la marca y el
// personaje son la misma forma vista dos veces. Por eso funciona al lado del
// logo sin competirle.
//
// El estado no es decoración: cada pantalla vacía dice algo distinto, y la
// cara lo dice antes que el texto. Dormida cuando no pasó nada todavía,
// buscando cuando falta cargarle información, alerta cuando algo se rompió.
defineProps({
estado: {
type: String,
default: 'normal', // normal | durmiendo | contenta | pensando | buscando | alerta
},
tam: { type: [Number, String], default: 72 },
})
</script>
<template>
<svg
:width="tam"
:height="tam"
viewBox="0 0 120 120"
class="shrink-0"
role="img"
:aria-label="`Umi, la mascota de uMind (${estado})`"
>
<!-- Antena: el punto del logo. Cambia de color solo cuando algo anda mal,
para que el rojo signifique algo. -->
<circle
cx="60"
cy="26"
r="6"
:fill="estado === 'alerta' ? '#dc2626' : 'currentColor'"
:class="estado === 'pensando' ? 'umi-late' : ''"
/>
<line
x1="60" y1="32" x2="60" y2="44"
:stroke="estado === 'alerta' ? '#dc2626' : 'currentColor'"
stroke-width="4"
stroke-linecap="round"
/>
<!-- Cuerpo: la "u" del logo, cerrada. -->
<path d="M30,46 V76 A30,30 0 0 0 90,76 V46 Z" fill="currentColor" />
<!-- Ojos abiertos -->
<template v-if="estado === 'normal' || estado === 'contenta' || estado === 'alerta'">
<circle cx="47" :cy="estado === 'contenta' ? 64 : 66" r="6" class="umi-ojo" />
<circle cx="73" :cy="estado === 'contenta' ? 64 : 66" r="6" class="umi-ojo" />
<circle cx="48" :cy="estado === 'contenta' ? 65 : 67" r="3" class="umi-pupila" />
<circle cx="74" :cy="estado === 'contenta' ? 65 : 67" r="3" class="umi-pupila" />
</template>
<!-- Ojos cerrados: no pasó nada todavía, no hay nada roto -->
<template v-else-if="estado === 'durmiendo'">
<path d="M41,66 h12 M67,66 h12" class="umi-linea" stroke-width="5" stroke-linecap="round" fill="none" />
</template>
<!-- Pensando: los tres puntos de "escribiendo…" que ya conoce cualquiera -->
<template v-else-if="estado === 'pensando'">
<circle cx="45" cy="66" r="4.5" class="umi-ojo umi-p1" />
<circle cx="60" cy="66" r="4.5" class="umi-ojo umi-p2" />
<circle cx="75" cy="66" r="4.5" class="umi-ojo umi-p3" />
</template>
<!-- Buscando: mira de costado, como quien revisa algo -->
<template v-else-if="estado === 'buscando'">
<circle cx="47" cy="66" r="6" class="umi-ojo" />
<circle cx="73" cy="66" r="6" class="umi-ojo" />
<circle cx="50" cy="66" r="3" class="umi-pupila" />
<circle cx="76" cy="66" r="3" class="umi-pupila" />
</template>
<!-- Sonrisa solo cuando hay algo que celebrar -->
<path
v-if="estado === 'contenta'"
d="M50,78 q10,8 20,0"
class="umi-linea"
stroke-width="4"
fill="none"
stroke-linecap="round"
/>
</svg>
</template>
<style scoped>
/* Los ojos toman el color de la superficie que tienen detrás, no blanco fijo:
así la mascota se apoya sobre cualquier tarjeta sin un halo alrededor de
cada ojo, y en modo oscuro no quedan dos puntos blancos flotando.
Los tokens del proyecto son tripletes RGB, de ahí el rgb(). */
.umi-ojo { fill: rgb(var(--superficie)); }
.umi-linea { stroke: rgb(var(--superficie)); }
/* La pupila sí es fija: es lo único que da la sensación de mirada, y tiene
que leerse igual sobre el verde en los dos temas. */
.umi-pupila { fill: #11150F; }
.umi-late { animation: umi-latido 2s ease-in-out infinite; }
@keyframes umi-latido {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.umi-p1, .umi-p2, .umi-p3 { animation: umi-punto 1.4s ease-in-out infinite; }
.umi-p2 { animation-delay: 0.18s; }
.umi-p3 { animation-delay: 0.36s; }
@keyframes umi-punto {
0%, 60%, 100% { opacity: 0.35; }
30% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.umi-late, .umi-p1, .umi-p2, .umi-p3 { animation: none; opacity: 1; }
}
</style>
@@ -0,0 +1,21 @@
<script setup>
import UiIcono from './UiIcono.vue'
defineProps({ titulo: { type: String, default: '' }, ancho: { type: String, default: 'max-w-lg' } })
const emit = defineEmits(['cerrar'])
</script>
<template>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
@click.self="emit('cerrar')"
@keydown.esc="emit('cerrar')"
>
<div :class="['card w-full p-6 max-h-[90vh] overflow-y-auto animate-escalar shadow-2xl', ancho]">
<div v-if="titulo" class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-texto">{{ titulo }}</h2>
<button class="btn-ghost !px-2 !py-1" type="button" @click="emit('cerrar')"><UiIcono nombre="cerrar" :tam="15" /></button>
</div>
<slot />
</div>
</div>
</template>
+35
View File
@@ -0,0 +1,35 @@
import { contexto } from './contexto.js'
// Wrapper de fetch para las rutas de sesión de uMind. La cookie de sesión
// viaja sola por ser mismo origen. Si la sesión expiró, tanto AuthWeb() como
// PortalAuth() redirigen al login devolviendo HTML en vez de un 401 JSON —
// fetch sigue ese redirect solo, así que lo detectamos por el content-type.
async function request(path, options = {}) {
const res = await fetch(path, {
...options,
headers: { 'Content-Type': 'application/json', ...options.headers },
})
const contentType = res.headers.get('content-type') || ''
if (res.redirected || !contentType.includes('application/json')) {
window.location.href = contexto.urlLogin
throw new Error('Sesión expirada')
}
const data = await res.json()
if (!res.ok) {
// El backend responde {"error": "..."} pero algunos handlers viejos usan
// {"error": true, "message": "..."} — sin este chequeo el usuario veía
// literalmente "true" como mensaje de error.
const msg = typeof data?.error === 'string' ? data.error : data?.message
throw new Error(msg || 'Error de servidor')
}
return data
}
export const api = {
get: (path) => request(path),
post: (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) }),
put: (path, body) => request(path, { method: 'PUT', body: JSON.stringify(body) }),
del: (path) => request(path, { method: 'DELETE' }),
}
+21
View File
@@ -0,0 +1,21 @@
// El mismo build se sirve en dos lugares: /orchestrator (staff, sesión de
// panel) y /portal/studio (cliente, sesión de portal). Ambos exponen los
// mismos endpoints de uMind pero bajo distinto prefijo y con distinto
// alcance, así que la app deduce dónde está parada mirando la URL.
const enPortal = window.location.pathname.startsWith('/portal/')
export const contexto = {
esPortal: enPortal,
// Base del router (vue-router en modo history).
baseRuta: enPortal ? '/portal/studio/' : '/studio/',
// Prefijo de la API de uMind.
apiBase: enPortal ? '/portal' : '/app',
// A dónde mandar al usuario cuando se le venció la sesión.
urlLogin: enPortal ? '/portal/login' : '/login',
}
// apiUmind arma la ruta de un endpoint de uMind para el contexto actual:
// apiUmind('/umind/agentes') → '/app/umind/agentes' o '/portal/umind/agentes'
export function apiUmind(path) {
return contexto.apiBase + path
}
+23
View File
@@ -0,0 +1,23 @@
import { ref } from 'vue'
// El tema se aplica antes de montar la app para que no haya un parpadeo
// claro en quien tiene el oscuro elegido.
const GUARDADO = 'umind-tema'
function preferidoDelSistema() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'oscuro' : 'claro'
}
export const tema = ref(localStorage.getItem(GUARDADO) || preferidoDelSistema())
export function aplicarTema() {
document.documentElement.classList.toggle('dark', tema.value === 'oscuro')
}
export function alternarTema() {
tema.value = tema.value === 'oscuro' ? 'claro' : 'oscuro'
localStorage.setItem(GUARDADO, tema.value)
aplicarTema()
}
aplicarTema()
+6
View File
@@ -0,0 +1,6 @@
import { ref } from 'vue'
// Estado del menú lateral en móvil. Vive acá y no en App.vue porque lo tocan
// dos componentes que no son padre/hijo: el botón del header y el propio
// sidebar, que se cierra solo al navegar.
export const menuAbierto = ref(false)
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
import './style.css'
import './lib/tema.js' // aplica el tema antes de montar, para no parpadear
createApp(App).use(router).mount('#app')
+25
View File
@@ -0,0 +1,25 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from './views/Home.vue'
import TenantAgentes from './views/TenantAgentes.vue'
import AgenteDetail from './views/AgenteDetail.vue'
import Uso from './views/Uso.vue'
import AiPropia from './views/AiPropia.vue'
import { contexto } from './lib/contexto.js'
const router = createRouter({
history: createWebHistory(contexto.baseRuta),
routes: [
{ path: '/', name: 'home', component: Home },
{ path: '/tenants/:id', name: 'tenant-agentes', component: TenantAgentes, props: true },
{ path: '/tenants/:id/uso', name: 'uso', component: Uso, props: true },
{ path: '/tenants/:id/ia', name: 'ai-propia', component: AiPropia, props: true },
{
path: '/tenants/:tenantId/agentes/:agenteId',
name: 'agente-detail',
component: AgenteDetail,
props: true,
},
],
})
export default router
+145
View File
@@ -0,0 +1,145 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Los colores viven acá una sola vez, en claro y oscuro. Antes cada elemento
repetía el par (bg-white dark:bg-gray-900, border-gray-200 dark:border-...)
a mano en cientos de lugares; cambiar un tono era buscar y reemplazar. */
@layer base {
:root {
--superficie: 255 255 255;
--elevado: 249 250 251;
--borde: 229 231 235;
--texto: 31 41 55;
--tenue: 107 114 128;
--fondo: 249 250 251;
}
.dark {
--superficie: 17 24 39;
--elevado: 24 33 51;
--borde: 42 52 70;
--texto: 229 231 235;
--tenue: 148 163 184;
--fondo: 9 13 22;
}
body {
background: rgb(var(--fondo));
color: rgb(var(--texto));
-webkit-font-smoothing: antialiased;
}
/* Retícula de plano técnico, muy tenue y ESTÁTICA. Da profundidad sin pedir
atención ni gastar un canvas corriendo todo el día. Se desvanece hacia
abajo para no competir con las tablas y los formularios, que es donde de
verdad se trabaja. */
body::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image:
linear-gradient(rgb(var(--borde) / 0.5) 1px, transparent 1px),
linear-gradient(90deg, rgb(var(--borde) / 0.5) 1px, transparent 1px);
background-size: 56px 56px;
-webkit-mask-image: radial-gradient(ellipse 90% 55% at 50% 0%, #000 10%, transparent 70%);
mask-image: radial-gradient(ellipse 90% 55% at 50% 0%, #000 10%, transparent 70%);
opacity: 0.55;
}
/* El contenido va por encima de la retícula. */
#app { position: relative; z-index: 1; }
}
@layer components {
.card {
@apply bg-superficie border border-borde rounded-xl;
}
.input {
@apply w-full bg-superficie border border-borde text-texto rounded-lg px-3 py-2 text-sm
placeholder:text-tenue/60 outline-none transition-colors
focus:border-brand focus:ring-2 focus:ring-brand/20;
}
.label {
@apply block text-xs font-medium text-tenue mb-1;
}
.btn {
@apply inline-flex items-center justify-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium
transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-brand text-white hover:bg-brand-dark;
}
.btn-ghost {
@apply btn text-tenue hover:text-texto hover:bg-elevado;
}
.btn-peligro {
@apply btn text-red-600 hover:bg-red-50 dark:hover:bg-red-950/40;
}
.badge {
@apply inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium;
}
.badge-ok {
@apply badge bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-400;
}
.badge-alerta {
@apply badge bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400;
}
.badge-error {
@apply badge bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-400;
}
.badge-neutro {
@apply badge bg-elevado text-tenue;
}
.tab {
@apply px-3 py-1.5 rounded-full text-sm transition-colors whitespace-nowrap;
}
.tab-activo {
@apply tab bg-brand text-white;
}
.tab-inactivo {
@apply tab text-tenue hover:text-texto hover:bg-elevado;
}
}
@layer components {
/* Deslizable con el dedo, sin la barra gris encima del contenido. El borde
cortado de la última pestaña ya avisa que hay más hacia el costado. */
.sin-barra {
scrollbar-width: none;
-ms-overflow-style: none;
}
.sin-barra::-webkit-scrollbar {
display: none;
}
}
@layer components {
/* Un punto que late para lo que está trabajando de verdad: una fuente en
"procesando" con la etiqueta quieta no dice si sigue avanzando o si se
colgó. Se usa sólo donde hay trabajo real en curso, nunca de adorno. */
.latido {
width: 5px;
height: 5px;
border-radius: 9999px;
background: currentColor;
animation: latido 1.4s ease-in-out infinite;
}
@keyframes latido {
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.latido { animation: none; opacity: 0.8; }
}
}
+568
View File
@@ -0,0 +1,568 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.js'
import UiEmptyState from '../components/ui/UiEmptyState.vue'
import UiIcono from '../components/ui/UiIcono.vue'
import TabAuditoria from './agente/TabAuditoria.vue'
import TabConexiones from './agente/TabConexiones.vue'
import TabChat from './agente/TabChat.vue'
import TabConversaciones from './agente/TabConversaciones.vue'
import TabHerramientas from './agente/TabHerramientas.vue'
import TabCanales from './agente/TabCanales.vue'
import EstadoAgente from './agente/EstadoAgente.vue'
const props = defineProps({
tenantId: { type: String, required: true },
agenteId: { type: String, required: true },
})
const agenteIdNum = computed(() => Number(props.agenteId))
const route = useRoute()
const agente = ref(null)
const error = ref('')
const tab = ref(
typeof route.query.tab === 'string'
? route.query.tab
: contexto.esPortal
? 'conversaciones'
: 'conocimiento',
)
// ─── Base de conocimiento ───────────────────────────────────────────────────
const documentos = ref([])
const nuevaUrl = ref('')
const maxPaginas = ref(30)
const ingestando = ref(false)
async function cargarAgente() {
const r = await api.get(apiUmind(`/umind/agentes?tenant_id=${props.tenantId}`))
agente.value = (r.items || []).find((x) => String(x.ID) === props.agenteId) || null
}
async function cargarDocumentos() {
const r = await api.get(apiUmind(`/umind/documentos?agente_id=${props.agenteId}`))
documentos.value = r.items || []
}
// Tres formas de cargar conocimiento, no una: la web, un archivo (lista de
// precios, condiciones) y lo que el dueño escribe a mano, que es lo más
// valioso y lo único que no está en ningún documento.
const fuenteNueva = ref('texto')
const autoActualizar = ref(true)
const notaTitulo = ref('')
const notaTexto = ref('')
const archivoRef = ref(null)
async function agregarFuente() {
if (!nuevaUrl.value.trim()) return
ingestando.value = true
error.value = ''
try {
await api.post(apiUmind('/umind/documentos'), {
agente_id: agenteIdNum.value,
url: nuevaUrl.value.trim(),
max_paginas: Number(maxPaginas.value) || 30,
auto_actualizar: autoActualizar.value,
})
nuevaUrl.value = ''
await cargarDocumentos()
} catch (e) {
error.value = e.message
} finally {
ingestando.value = false
}
}
async function agregarNota() {
if (!notaTexto.value.trim()) return
ingestando.value = true
error.value = ''
try {
await api.post(apiUmind('/umind/documentos/texto'), {
agente_id: agenteIdNum.value,
titulo: notaTitulo.value.trim(),
contenido: notaTexto.value,
})
notaTitulo.value = ''
notaTexto.value = ''
await cargarDocumentos()
} catch (e) {
error.value = e.message
} finally {
ingestando.value = false
}
}
async function subirArchivo() {
const f = archivoRef.value?.files?.[0]
if (!f) return
ingestando.value = true
error.value = ''
try {
const fd = new FormData()
fd.append('agente_id', String(agenteIdNum.value))
fd.append('archivo', f)
// Sin Content-Type a mano: el navegador tiene que poner el boundary.
const res = await fetch(apiUmind('/umind/documentos/archivo'), { method: 'POST', body: fd })
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'No se pudo subir')
archivoRef.value.value = ''
await cargarDocumentos()
} catch (e) {
error.value = e.message
} finally {
ingestando.value = false
}
}
// Editar una fuente escrita a mano. Las plantillas de rubro dejan las notas
// con valores entre corchetes para reemplazar — sin esto, no había con qué.
const editandoDoc = ref(null)
const docForm = ref({ titulo: '', contenido: '' })
const guardandoDoc = ref(false)
function editarDocumento(d) {
editandoDoc.value = d
docForm.value = { titulo: d.origen, contenido: d.contenido || '' }
}
async function guardarDocumento() {
if (!editandoDoc.value) return
guardandoDoc.value = true
error.value = ''
try {
await api.put(apiUmind(`/umind/documentos/${editandoDoc.value.ID}`), {
titulo: docForm.value.titulo,
contenido: docForm.value.contenido,
})
editandoDoc.value = null
await cargarDocumentos()
} catch (e) {
error.value = e.message
} finally {
guardandoDoc.value = false
}
}
async function reprocesar(d) {
error.value = ''
try {
await api.post(apiUmind(`/umind/documentos/${d.ID}/reprocesar`), {})
await cargarDocumentos()
} catch (e) {
error.value = e.message
}
}
async function alternarAuto(d) {
try {
await api.put(apiUmind(`/umind/documentos/${d.ID}`), { auto_actualizar: !d.auto_actualizar })
await cargarDocumentos()
} catch (e) {
error.value = e.message
}
}
// "hace 3 días" en vez de una fecha: lo que importa no es cuándo se procesó
// sino qué tan viejo es lo que el agente está contestando.
function antiguedad(fecha) {
if (!fecha) return 'sin procesar'
const dias = Math.floor((Date.now() - new Date(fecha)) / 86400000)
if (dias <= 0) return 'hoy'
if (dias === 1) return 'ayer'
if (dias < 30) return `hace ${dias} días`
const meses = Math.floor(dias / 30)
return meses === 1 ? 'hace un mes' : `hace ${meses} meses`
}
function estaVieja(d) {
if (!d.procesado_at) return false
return Date.now() - new Date(d.procesado_at) > 60 * 86400000
}
async function eliminarDocumento(id) {
if (!confirm('¿Eliminar esta fuente y sus fragmentos indexados?')) return
await api.del(apiUmind(`/umind/documentos/${id}`))
await cargarDocumentos()
}
const estadoColor = computed(() => (estado) => ({
listo: 'badge-ok',
procesando: 'badge-alerta',
pendiente: 'badge-neutro',
error: 'badge-error',
}[estado] || 'badge-neutro'))
// ─── Conversaciones ──────────────────────────────────────────────────────────
const sesiones = ref([])
async function cargarSesiones() {
const r = await api.get(apiUmind(`/umind/sesiones?agente_id=${props.agenteId}`))
sesiones.value = r.items || []
}
// ─── Herramientas ───────────────────────────────────────────────────────────
const tools = ref([])
async function cargarTools() {
const r = await api.get(apiUmind(`/umind/tools?agente_id=${props.agenteId}`))
tools.value = r.items || []
}
// ─── Canales ────────────────────────────────────────────────────────────────
const canales = ref([])
async function cargarCanales() {
const r = await api.get(apiUmind(`/umind/canales?agente_id=${props.agenteId}`))
canales.value = r.items || []
}
// El enlace del reporte vive acá y no en la pestaña de canales: lo usa el
// encabezado, que se ve en todas.
const urlReporte = computed(() => {
const hoy = new Date()
const desde = new Date(hoy.getFullYear(), hoy.getMonth(), 1).toISOString().slice(0, 10)
const hasta = hoy.toISOString().slice(0, 10)
return apiUmind(`/umind/reporte.xlsx?agente_id=${props.agenteId}&desde=${desde}&hasta=${hasta}`)
})
// ─── Conexiones (correo, OAuth) ────────────────────────────────────────────────
const conexiones = ref([])
async function cargarConexiones() {
const r = await api.get(apiUmind(`/umind/conexiones?agente_id=${props.agenteId}`))
conexiones.value = r.items || []
}
// Tres zonas en vez de siete pestañas. Las siete eran nuestras siete tablas
// —documentos, tools, canales, conexiones, eventos— y un dueño de negocio no
// piensa "voy a Conexiones": piensa "¿por qué contestó mal?" o "quiero que
// sepa mis precios nuevos".
//
// Lo que se usa todos los días queda arriba; lo que se toca una vez y se
// olvida, en Avanzado.
const ZONAS = [
['conversaciones', 'Conversaciones'],
['conocimiento', 'Lo que sabe'],
['canales', 'Dónde atiende'],
]
const AVANZADO = [
['herramientas', 'Herramientas'],
['conexiones', 'Cuentas conectadas'],
['auditoria', 'Problemas'],
]
const mostrarAvanzado = ref(false)
// Si se entra directo a una pestaña avanzada (por la URL o desde el estado de
// arriba), la sección tiene que verse abierta o el botón activo queda
// escondido detrás de un "Avanzado" cerrado.
if (AVANZADO.some(([k]) => k === tab.value)) mostrarAvanzado.value = true
function irA(destino) {
tab.value = destino
if (AVANZADO.some(([k]) => k === destino)) mostrarAvanzado.value = true
}
// ─── Auditoría ────────────────────────────────────────────────────────────────
const eventos = ref([])
async function cargarEventos() {
const r = await api.get(apiUmind(`/umind/eventos?agente_id=${props.agenteId}`))
eventos.value = r.items || []
}
// Se observan los dos parámetros: al saltar de un agente a otro (o a un
// agente de otro tenant) vue-router reusa la instancia y onMounted no vuelve
// a correr, así que la pantalla quedaba con los datos del agente anterior.
watch(
() => [props.tenantId, props.agenteId],
async () => {
error.value = ''
// Se limpia antes de pedir: si no, durante la carga se ven los datos del
// agente anterior bajo el nombre del nuevo.
agente.value = null
documentos.value = []
sesiones.value = []
tools.value = []
canales.value = []
conexiones.value = []
eventos.value = []
try {
await Promise.all([cargarAgente(), cargarDocumentos(), cargarSesiones(), cargarTools(), cargarCanales(), cargarConexiones(), cargarEventos()])
} catch (e) {
error.value = e.message
}
},
{ immediate: true },
)
</script>
<template>
<div>
<router-link :to="`/tenants/${tenantId}`" class="label hover:text-brand">&larr; Agentes</router-link>
<div v-if="agente" class="mb-4 mt-1 flex items-start justify-between gap-4">
<h1 class="text-xl font-semibold text-texto min-w-0 truncate">{{ agente.nombre }}</h1>
<!-- Un archivo que se reenvía sirve para justificar el gasto puertas
adentro; un panel al que hay que entrar, no. -->
<a :href="urlReporte" class="btn-ghost shrink-0" title="Conversaciones y consumo del mes en Excel">
<UiIcono nombre="descargar" :tam="15" /> Reporte del mes
</a>
</div>
<!-- El estado va antes que la navegación: es lo primero que se quiere
saber y, además, lleva directo a lo que haya que arreglar. -->
<EstadoAgente
v-if="agente"
:canales="canales"
:documentos="documentos"
:sesiones="sesiones"
:eventos="eventos"
@ir="irA"
/>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
<div class="flex items-center gap-1.5 mb-6 flex-wrap">
<button
v-for="[key, label] in ZONAS"
:key="key"
class="px-3.5 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors"
:class="tab === key
? 'bg-brand text-white font-medium'
: 'bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 border border-borde hover:border-brand/50'"
@click="tab = key"
>
{{ label }}
</button>
<!-- Lo que se configura una vez no puede competir todos los días con lo
que se mira todos los días. -->
<button
class="ml-auto text-xs text-tenue hover:text-texto inline-flex items-center gap-1"
@click="mostrarAvanzado = !mostrarAvanzado"
>
Avanzado
<span class="text-[10px]">{{ mostrarAvanzado ? '▲' : '▼' }}</span>
</button>
</div>
<div v-if="mostrarAvanzado" class="flex gap-1.5 mb-6 flex-wrap">
<button
v-for="[key, label] in AVANZADO"
:key="key"
class="px-3 py-1 rounded-full text-xs whitespace-nowrap transition-colors"
:class="tab === key
? 'bg-texto text-white dark:bg-gray-200 dark:text-gray-900 font-medium'
: 'bg-white dark:bg-gray-900 text-tenue border border-borde hover:border-brand/50'"
@click="tab = key"
>
{{ label }}
</button>
</div>
<!-- Lo que sabe: el conocimiento y la prueba, lado a lado.
El bucle real es leer lo que sabe probar corregir probar de
nuevo. Con las dos cosas en pestañas separadas, eso eran seis clics
por corrección; acá es escribir y ver. -->
<div v-if="tab === 'conocimiento'" class="grid gap-4 lg:grid-cols-[1fr_21rem] items-start">
<div>
<div class="card p-4 mb-4">
<div class="flex gap-1 mb-3">
<button
v-for="[k, ico, l] in [['texto', 'escribir', 'Escribir'], ['archivo', 'archivo', 'Subir archivo'], ['url', 'sitio', 'Sitio web']]"
:key="k"
class="px-3 py-1.5 rounded-lg text-sm inline-flex items-center gap-1.5"
:class="fuenteNueva === k ? 'bg-brand text-white font-medium' : 'text-tenue hover:text-texto'"
@click="fuenteNueva = k"
><UiIcono :nombre="ico" :tam="15" /> {{ l }}</button>
</div>
<!-- Lo que el dueño sabe y no está escrito en ningún lado. -->
<form v-if="fuenteNueva === 'texto'" class="space-y-2" @submit.prevent="agregarNota">
<input v-model="notaTitulo" placeholder="Título (ej: Horarios y zonas de entrega)" class="input" />
<textarea
v-model="notaTexto"
rows="5"
required
placeholder="Atendemos de lunes a viernes de 9 a 18. No hacemos envíos fuera de la ciudad. La garantía es de 6 meses y cubre…"
class="input font-normal"
></textarea>
<div class="flex items-center justify-between">
<p class="text-xs text-tenue">Lo que te preguntan todos los días y no está en tu web.</p>
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
{{ ingestando ? 'Guardando…' : 'Guardar' }}
</button>
</div>
</form>
<form v-else-if="fuenteNueva === 'archivo'" class="space-y-2" @submit.prevent="subirArchivo">
<input ref="archivoRef" type="file" accept=".pdf,.docx,.txt,.md,.csv,.html,image/*" class="text-sm text-texto" />
<div class="flex items-center justify-between">
<p class="text-xs text-tenue">PDF, Word, texto o una foto. Ej: tu lista de precios.</p>
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
{{ ingestando ? 'Leyendo…' : 'Subir' }}
</button>
</div>
</form>
<form v-else class="space-y-2" @submit.prevent="agregarFuente">
<div class="flex gap-2">
<input v-model="nuevaUrl" type="url" placeholder="https://ejemplo.com" required class="input flex-1" />
<input v-model="maxPaginas" type="number" min="1" max="200" class="input w-24" title="Máximo de páginas a leer" />
</div>
<div class="flex items-center justify-between">
<label class="flex items-center gap-2 text-xs text-tenue cursor-pointer">
<input v-model="autoActualizar" type="checkbox" class="rounded border-borde text-brand focus:ring-brand" />
Releer el sitio cada semana
</label>
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
{{ ingestando ? 'Leyendo…' : 'Leer sitio' }}
</button>
</div>
</form>
</div>
<div class="card divide-y divide-borde">
<UiEmptyState
v-if="documentos.length === 0"
estado="buscando"
titulo="Todavía no sabe nada de tu negocio"
detalle="Sin información cargada va a contestar que no sabe. Empezá por escribir tus horarios y lo que te preguntan todos los días — es lo más rápido y lo que más se nota."
/>
<div v-for="d in documentos" :key="d.ID" class="p-4 flex items-center justify-between">
<div>
<div class="text-sm text-texto">{{ d.origen }}</div>
<div class="text-xs text-tenue mt-0.5">
<span class="px-1.5 py-0.5 rounded inline-flex items-center gap-1" :class="estadoColor(d.estado)">
<span v-if="d.estado === 'procesando'" class="latido"></span>
{{ d.estado }}
</span>
<span v-if="d.tipo"> · {{ { url: 'sitio', archivo: 'archivo', texto: 'nota' }[d.tipo] || d.tipo }}</span>
<span v-if="d.total_chunks"> · {{ d.total_chunks }} fragmentos</span>
<!-- La antigüedad, en rojo cuando pasó de dos meses: es lo único
que delata que el agente contesta con información vieja. -->
<span v-if="d.procesado_at" :class="estaVieja(d) ? 'text-amber-600 dark:text-amber-400 font-medium' : ''">
· leído {{ antiguedad(d.procesado_at) }}
</span>
<span v-if="d.auto_actualizar"> · se actualiza sola</span>
<span v-if="d.error" class="text-red-600 dark:text-red-400"> · {{ d.error }}</span>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<button
v-if="d.tipo === 'url'"
class="text-xs text-tenue hover:text-texto"
:title="d.auto_actualizar ? 'Dejar de releer sola' : 'Releer el sitio cada semana'"
@click="alternarAuto(d)"
><UiIcono :nombre="d.auto_actualizar ? 'auto' : 'refrescar'" :tam="13" class="mr-1" />{{ d.auto_actualizar ? 'auto' : 'manual' }}</button>
<!-- Solo lo que tiene texto propio guardado: una URL se rehace
crawleando, editarla a mano se perdería en la próxima pasada. -->
<button
v-if="d.tipo === 'texto' || d.tipo === 'archivo'"
class="text-sm text-brand hover:underline"
@click="editarDocumento(d)"
>Editar</button>
<button
class="text-sm text-tenue hover:text-texto disabled:opacity-40"
:disabled="d.estado === 'procesando'"
:title="d.tipo === 'url' ? 'Volver a leer el sitio' : 'Rehacer los fragmentos'"
@click="reprocesar(d)"
>Actualizar</button>
<button class="text-red-500 hover:text-red-700 text-sm" @click="eliminarDocumento(d.ID)">Eliminar</button>
</div>
</div>
</div>
</div>
<!-- La prueba, al lado y siempre visible mientras se edita el
conocimiento. En pantallas angostas cae abajo, que es el orden en
que igual se trabaja. -->
<div class="lg:sticky lg:top-4">
<p class="label mb-2">Probalo</p>
<TabChat :agente-id="agenteIdNum" />
</div>
<!-- Editar una nota o el texto extraído de un archivo -->
<div
v-if="editandoDoc"
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
@click.self="editandoDoc = null"
>
<div class="card w-full max-w-2xl p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
<h2 class="font-semibold text-texto mb-1">Editar fuente</h2>
<p class="text-xs text-tenue mb-4">
Al guardar se vuelve a leer y el agente empieza a contestar con esto.
</p>
<form class="space-y-3" @submit.prevent="guardarDocumento">
<div>
<label class="label">Título</label>
<input v-model="docForm.titulo" required class="input" />
</div>
<div>
<label class="label">Contenido</label>
<textarea
v-model="docForm.contenido"
rows="16"
required
class="input font-normal leading-relaxed"
></textarea>
<p class="text-xs text-tenue mt-1.5">
Si viene de una plantilla, reemplazá lo que está [entre corchetes] por tus datos.
Lo que quede sin reemplazar el agente lo va a leer tal cual.
</p>
</div>
<div class="flex justify-end gap-2 pt-1">
<button type="button" class="btn-ghost" @click="editandoDoc = null">Cancelar</button>
<button type="submit" class="btn-primary" :disabled="guardandoDoc">
{{ guardandoDoc ? 'Guardando…' : 'Guardar y volver a leer' }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Herramientas -->
<TabHerramientas
v-else-if="tab === 'herramientas'"
:agente-id="agenteIdNum"
:tools="tools"
@recargar="cargarTools"
@error="(m) => (error = m)"
/>
<TabCanales
v-else-if="tab === 'canales'"
:agente-id="agenteIdNum"
:site-key="agente?.site_key || ''"
:canales="canales"
@recargar="cargarCanales"
@error="(m) => (error = m)"
/>
<TabConexiones
v-else-if="tab === 'conexiones'"
:agente-id="agenteIdNum"
:conexiones="conexiones"
@recargar="cargarConexiones"
/>
<!-- Chat de prueba -->
<TabChat v-else-if="tab === 'chat'" :agente-id="agenteIdNum" />
<!-- Conversaciones -->
<TabConversaciones
v-else-if="tab === 'conversaciones'"
:agente-id="agenteIdNum"
:sesiones="sesiones"
/>
<!-- Auditoría -->
<TabAuditoria v-else :eventos="eventos" />
</div>
</template>
+256
View File
@@ -0,0 +1,256 @@
<script setup>
import { ref, watch } from 'vue'
import { api } from '../lib/api.js'
import { apiUmind } from '../lib/contexto.js'
import UiEmptyState from '../components/ui/UiEmptyState.vue'
import UiIcono from '../components/ui/UiIcono.vue'
const props = defineProps({ id: { type: String, required: true } })
const items = ref([])
const cargando = ref(true)
const error = ref('')
const showForm = ref(false)
const editando = ref(null)
const guardando = ref(false)
const probando = ref(null)
const resultado = ref({})
// Los proveedores que acepta el backend. El texto de ayuda de cada uno importa
// más que la lista: nadie recuerda de memoria el nombre exacto de un modelo.
const proveedores = [
{ valor: 'openai', nombre: 'OpenAI', modelo: 'gpt-4o-mini', donde: 'platform.openai.com/api-keys' },
{ valor: 'anthropic', nombre: 'Anthropic (Claude)', modelo: 'claude-sonnet-4-20250514', donde: 'console.anthropic.com' },
{ valor: 'gemini', nombre: 'Google Gemini', modelo: 'gemini-2.0-flash', donde: 'aistudio.google.com/apikey' },
{ valor: 'groq', nombre: 'Groq', modelo: 'llama-3.3-70b-versatile', donde: 'console.groq.com/keys' },
{ valor: 'deepseek', nombre: 'DeepSeek', modelo: 'deepseek-chat', donde: 'platform.deepseek.com' },
{ valor: 'qwen', nombre: 'Qwen (Alibaba)', modelo: 'qwen2.5-72b-instruct', donde: 'dashscope.console.aliyun.com' },
{ valor: 'ollama', nombre: 'Ollama (tu servidor)', modelo: 'llama3.1', donde: '' },
]
function vacio() {
return { nombre: '', provider: 'openai', api_key: '', base_url: '', model_name: '' }
}
const form = ref(vacio())
function proveedorDe(valor) {
return proveedores.find((p) => p.valor === valor) || proveedores[0]
}
async function cargar() {
cargando.value = true
error.value = ''
try {
const r = await api.get(apiUmind(`/umind/ai-propia?tenant_id=${props.id}`))
items.value = r.items || []
} catch (e) {
error.value = e.message
} finally {
cargando.value = false
}
}
watch(() => props.id, cargar, { immediate: true })
function nueva() {
editando.value = null
form.value = vacio()
showForm.value = true
}
function editar(c) {
editando.value = c
// La clave no vuelve del servidor: se deja en blanco y solo se manda si la
// persona escribe una nueva.
form.value = {
nombre: c.nombre,
provider: c.provider,
api_key: '',
base_url: c.base_url || '',
model_name: c.model_name || '',
}
showForm.value = true
}
async function guardar() {
guardando.value = true
error.value = ''
try {
const cuerpo = { tenant_id: Number(props.id), ...form.value }
if (editando.value) {
await api.put(apiUmind(`/umind/ai-propia/${editando.value.ID}`), cuerpo)
} else {
await api.post(apiUmind('/umind/ai-propia'), cuerpo)
}
showForm.value = false
await cargar()
} catch (e) {
error.value = e.message
} finally {
guardando.value = false
}
}
// Probar contra el proveedor de verdad. Una clave vencida no se nota hasta que
// alguien escribe: esto lo adelanta.
async function probar(c) {
probando.value = c.ID
resultado.value = { ...resultado.value, [c.ID]: null }
try {
const r = await api.post(apiUmind(`/umind/ai-propia/${c.ID}/probar`), {})
resultado.value = { ...resultado.value, [c.ID]: { ok: true, texto: r.respuesta } }
} catch (e) {
resultado.value = { ...resultado.value, [c.ID]: { ok: false, texto: e.message } }
} finally {
probando.value = null
}
}
async function borrar(c) {
if (!confirm(`¿Eliminar "${c.nombre}"?`)) return
error.value = ''
try {
await api.del(apiUmind(`/umind/ai-propia/${c.ID}`))
await cargar()
} catch (e) {
error.value = e.message
}
}
</script>
<template>
<div>
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<router-link :to="`/tenants/${id}`" class="text-sm text-tenue hover:text-texto inline-flex items-center gap-1.5"><UiIcono nombre="atras" :tam="14" /> Agentes</router-link>
<h1 class="text-xl font-semibold text-texto mt-1">Tu cuenta de IA</h1>
<p class="text-sm text-tenue mt-1 max-w-xl">
Podés usar tu propia cuenta de OpenAI, Claude o Gemini en vez de la nuestra.
El consumo se factura directo con el proveedor y no pasa por tu plan.
</p>
</div>
<button class="btn-primary shrink-0" @click="nueva">+ Conectar cuenta</button>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
<div v-if="cargando" class="text-sm text-tenue">Cargando</div>
<UiEmptyState
v-else-if="items.length === 0"
class="card"
titulo="Estás usando la IA de uMind"
detalle="Funciona sin que hagas nada. Conectá tu propia cuenta solo si querés facturar el consumo por tu lado o usar un modelo puntual."
>
<button class="btn-primary" @click="nueva">Conectar mi cuenta</button>
</UiEmptyState>
<div v-else class="card divide-y divide-borde">
<div v-for="c in items" :key="c.ID" class="p-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-sm text-texto font-medium">{{ c.nombre }}</div>
<div class="text-xs text-tenue mt-0.5">
{{ proveedorDe(c.provider).nombre }} · {{ c.model_name }} ·
<span class="font-mono">{{ c.api_key_pista }}</span>
</div>
<p
v-if="resultado[c.ID]"
class="text-xs mt-1.5"
:class="resultado[c.ID].ok ? 'text-brand' : 'text-red-600 dark:text-red-400'"
>
{{ resultado[c.ID].ok ? `Responde bien — dijo "${resultado[c.ID].texto}"` : resultado[c.ID].texto }}
</p>
</div>
<div class="flex items-center gap-3 shrink-0 text-sm">
<button
class="text-brand hover:underline disabled:opacity-40"
:disabled="probando === c.ID"
@click="probar(c)"
>{{ probando === c.ID ? 'Probando…' : 'Probar' }}</button>
<button class="text-tenue hover:text-texto" @click="editar(c)">Editar</button>
<button class="text-red-500 hover:text-red-700" @click="borrar(c)">Eliminar</button>
</div>
</div>
</div>
</div>
<p v-if="items.length" class="text-xs text-tenue mt-3">
Para que un agente la use, elegila en su campo <strong>Config de IA</strong>.
</p>
<!-- Alta / edición -->
<div
v-if="showForm"
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
@click.self="showForm = false"
>
<div class="card w-full max-w-lg p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
<h2 class="font-semibold text-texto mb-4">
{{ editando ? 'Editar cuenta de IA' : 'Conectar tu cuenta de IA' }}
</h2>
<form class="space-y-3" @submit.prevent="guardar">
<div>
<label class="label">Nombre</label>
<input v-model="form.nombre" required placeholder="ej: Mi cuenta de OpenAI" class="input" />
</div>
<div>
<label class="label">Proveedor</label>
<select v-model="form.provider" class="input">
<option v-for="p in proveedores" :key="p.valor" :value="p.valor">{{ p.nombre }}</option>
</select>
<p v-if="proveedorDe(form.provider).donde" class="text-xs text-tenue mt-1">
La clave se saca de <span class="font-mono">{{ proveedorDe(form.provider).donde }}</span>
</p>
</div>
<div>
<label class="label">Modelo</label>
<input
v-model="form.model_name"
required
:placeholder="`ej: ${proveedorDe(form.provider).modelo}`"
class="input font-mono"
/>
</div>
<div v-if="form.provider !== 'ollama'">
<label class="label">
Clave de API
<span v-if="editando" class="text-tenue font-normal"> dejala vacía para no cambiarla</span>
</label>
<input
v-model="form.api_key"
type="password"
autocomplete="new-password"
:required="!editando"
placeholder="sk-..."
class="input font-mono"
/>
<p class="text-xs text-tenue mt-1">Se guarda cifrada y no se vuelve a mostrar.</p>
</div>
<div v-if="form.provider === 'ollama'">
<label class="label">URL de tu servidor</label>
<input
v-model="form.base_url"
required
placeholder="https://ollama.tudominio.com/v1"
class="input font-mono"
/>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="btn-ghost" @click="showForm = false">Cancelar</button>
<button type="submit" class="btn-primary" :disabled="guardando">
{{ guardando ? 'Guardando…' : 'Guardar' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
+81
View File
@@ -0,0 +1,81 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.js'
import UiMascota from '../components/ui/UiMascota.vue'
const router = useRouter()
const cargando = ref(contexto.esPortal)
const sinEspacios = ref(false)
// El cliente no tiene "tenants": tiene SU asistente. Si solo hay un espacio
// con un agente —el caso normal— entrar obligándolo a elegir dos veces es
// jerarquía nuestra, no suya. Se salta directo al agente.
//
// Con varios espacios o varios agentes sí hace falta elegir, así que ahí se
// muestra la pantalla de siempre.
async function irAlAgenteSiEsUnoSolo() {
if (!contexto.esPortal) return
try {
const t = await api.get(apiUmind('/umind/tenants'))
const tenants = t.items || []
if (tenants.length === 0) {
sinEspacios.value = true
return
}
if (tenants.length !== 1) return
const tenantId = tenants[0].ID
const a = await api.get(apiUmind(`/umind/agentes?tenant_id=${tenantId}`))
const agentes = a.items || []
if (agentes.length === 1) {
router.replace(`/tenants/${tenantId}/agentes/${agentes[0].ID}`)
return
}
router.replace(`/tenants/${tenantId}`)
} catch {
// Si algo falla se queda en esta pantalla, que igual deja navegar.
} finally {
cargando.value = false
}
}
onMounted(irAlAgenteSiEsUnoSolo)
</script>
<template>
<div v-if="cargando" class="flex flex-col items-center justify-center py-24 text-sm text-tenue">
Abriendo tu asistente
</div>
<!-- Sin espacios no hay nada que elegir ni forma de crearlo: mandarlo a
"elegí de la izquierda" cuando la izquierda está vacía es un callejón. -->
<div v-else-if="sinEspacios" class="max-w-md mx-auto text-center py-20">
<UiMascota estado="durmiendo" :tam="80" class="mx-auto mb-4 text-brand" />
<h1 class="text-lg font-medium text-texto">Todavía no tenés un asistente activo</h1>
<p class="text-sm text-tenue mt-2">
uMind contesta por vos en WhatsApp y en tu sitio, con la información de tu negocio.
Entiende las notas de voz y lee las fotos y archivos que te mandan tus clientes.
</p>
<a
class="btn-primary inline-block mt-5"
href="mailto:soporte@u-site.app?subject=Quiero%20activar%20uMind"
>Quiero activarlo</a>
<p class="text-xs text-tenue mt-4">
<a href="/portal/dashboard" class="underline">Volver al portal</a>
</p>
</div>
<div v-else class="flex flex-col items-center justify-center text-center py-24">
<UiMascota estado="normal" :tam="64" class="mx-auto mb-4 text-brand" />
<h1 class="text-lg font-medium text-texto">
{{ contexto.esPortal ? 'Elegí tu espacio de la izquierda' : 'Elegí un espacio de la izquierda' }}
</h1>
<p class="text-sm text-tenue mt-1">
{{ contexto.esPortal
? 'Adentro vas a poder crear y configurar tus agentes.'
: 'o creá uno nuevo para empezar a configurar su agente.' }}
</p>
</div>
</template>
+340
View File
@@ -0,0 +1,340 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.js'
import UiEmptyState from '../components/ui/UiEmptyState.vue'
import UiIcono from '../components/ui/UiIcono.vue'
const props = defineProps({ id: { type: String, required: true } })
const tenantId = computed(() => Number(props.id))
const router = useRouter()
const tenant = ref(null)
const agentes = ref([])
const aiConfigs = ref([])
const error = ref('')
const showForm = ref(false)
const editing = ref(null)
const form = ref(vacio())
const plan = ref(null)
const resumen = ref({})
const cargando = ref(true)
// Un agente sin fuentes de conocimiento responde de memoria e inventa datos
// —es exactamente el problema de la URL falsa— así que eso se avisa acá y no
// recién cuando un cliente recibe una respuesta inventada.
function estadoDe(a) {
const r = resumen.value[a.ID] || {}
if (!a.activo) return { tipo: 'neutro', texto: 'inactivo' }
if (!(r.documentos > 0)) return { tipo: 'alerta', texto: 'sin conocimiento' }
return { tipo: 'ok', texto: 'listo' }
}
function datosDe(a) {
const r = resumen.value[a.ID] || {}
return {
documentos: r.documentos || 0,
canales: r.canales || 0,
conversaciones: r.conversaciones_7d || 0,
}
}
// Iniciales para el avatar; el color de marca del agente lo tiñe.
function iniciales(nombre) {
return String(nombre || '?').trim().split(/\s+/).slice(0, 2).map(p => p[0]).join('').toUpperCase()
}
// max_agentes 0 = ilimitado. Sin plan asignado tampoco hay límite: es
// deliberado (los tenants que existían antes de los planes no se rompen),
// pero hay que decirlo, o parece que el límite está fallando.
const cupo = computed(() => {
if (!plan.value) return { sinPlan: true }
const max = plan.value.max_agentes || 0
return {
sinPlan: false,
nombre: plan.value.nombre,
ilimitado: max <= 0,
max,
usados: agentes.value.length,
lleno: max > 0 && agentes.value.length >= max,
}
})
function vacio() {
return { nombre: '', ai_config_id: null, tono: '', mensaje_bienvenida: '', color: '#8eb02f', activo: true, plantilla_rubro: '' }
}
// Puntos de partida por rubro. Un agente vacío no sirve el primer día, y
// escribir el conocimiento desde cero frente a un campo en blanco es donde la
// mayoría abandona.
const plantillas = ref([])
async function cargar() {
error.value = ''
cargando.value = true
try {
const [t, a, ai, pl] = await Promise.all([
api.get(apiUmind('/umind/tenants')),
api.get(apiUmind(`/umind/agentes?tenant_id=${props.id}`)),
api.get(apiUmind('/umind/ai-configs')),
api.get(apiUmind('/umind/plantillas-rubro')),
])
tenant.value = (t.items || []).find((x) => String(x.ID) === props.id) || null
agentes.value = a.items || []
plan.value = a.plan || null
resumen.value = a.resumen || {}
aiConfigs.value = ai.items || []
plantillas.value = pl.items || []
} catch (e) {
error.value = e.message
} finally {
cargando.value = false
}
}
// Un agente que ya funciona es la mejor plantilla del siguiente: el catálogo de
// rubros da un arranque genérico, esto copia uno real con su conocimiento.
async function duplicarAgente(a) {
const nombre = prompt(`Nombre de la copia de "${a.nombre}":`, `${a.nombre} (copia)`)
if (nombre === null) return
error.value = ''
try {
const r = await api.post(apiUmind(`/umind/agentes/${a.ID}/duplicar`), {
tenant_id: Number(tenantId.value),
nombre: nombre.trim(),
})
await cargar()
if (r.aviso) alert(r.aviso)
} catch (e) {
error.value = e.message
}
}
function nuevoAgente() {
editing.value = null
form.value = vacio()
showForm.value = true
}
function editarAgente(a) {
editing.value = a
form.value = {
nombre: a.nombre,
ai_config_id: a.ai_config_id,
tono: a.tono,
mensaje_bienvenida: a.mensaje_bienvenida,
color: a.color || '#8eb02f',
activo: a.activo,
}
showForm.value = true
}
async function guardar() {
try {
if (editing.value) {
await api.put(apiUmind(`/umind/agentes/${editing.value.ID}`), { tenant_id: tenantId.value, ...form.value })
showForm.value = false
await cargar()
} else {
const r = await api.post(apiUmind('/umind/agentes'), { tenant_id: tenantId.value, ...form.value })
showForm.value = false
router.push(`/tenants/${tenantId.value}/agentes/${r.id}`)
}
} catch (e) {
error.value = e.message
}
}
async function eliminarAgente(a) {
if (!confirm(`¿Eliminar el agente "${a.nombre}"? Esto no se puede deshacer.`)) return
await api.del(apiUmind(`/umind/agentes/${a.ID}`))
await cargar()
}
// watch con immediate en vez de onMounted: al navegar entre /tenants/2 y
// /tenants/3 vue-router reusa la misma instancia del componente, así que
// onMounted no vuelve a dispararse y la pantalla seguía mostrando los agentes
// del tenant anterior.
watch(() => props.id, cargar, { immediate: true })
</script>
<template>
<div>
<div v-if="tenant" class="mb-6">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-xl font-semibold text-texto">{{ tenant.nombre }}</h1>
<p class="text-xs text-tenue mt-1">{{ tenant.dominios_permitidos || 'sin dominios configurados' }}</p>
</div>
<router-link :to="`/tenants/${tenantId ?? id}/ia`" class="btn-ghost">Tu IA</router-link>
<router-link :to="`/tenants/${tenantId ?? id}/uso`" class="btn-ghost inline-flex items-center gap-1.5"><UiIcono nombre="grafico" /> Consumo</router-link>
</div>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<h2 class="text-sm font-medium text-tenue">Agentes</h2>
<span v-if="cupo.sinPlan" class="badge-alerta">sin plan · sin límite</span>
<span v-else-if="cupo.ilimitado" class="badge-neutro">{{ cupo.nombre }} · ilimitado</span>
<span v-else :class="cupo.lleno ? 'badge-alerta' : 'badge-neutro'">
{{ cupo.nombre }} · {{ cupo.usados }} de {{ cupo.max }}
</span>
</div>
<button class="btn-primary" :disabled="cupo.lleno" @click="nuevoAgente">+ Nuevo agente</button>
</div>
<p v-if="cupo.sinPlan && !contexto.esPortal" class="text-xs text-tenue -mt-2 mb-4">
Este espacio no tiene plan asignado, así que no se le aplica ningún límite de agentes. Asignale uno desde el lápiz del espacio, en la barra izquierda.
</p>
<p v-else-if="cupo.lleno" class="text-xs text-tenue -mt-2 mb-4">
Alcanzaste el máximo de agentes de tu plan.
</p>
<!-- Esqueleto: la carga hace 3 llamadas, y sin esto la pantalla queda en
blanco el tiempo suficiente como para parecer rota. -->
<div v-if="cargando" class="grid gap-3 sm:grid-cols-2">
<div v-for="n in 2" :key="n" class="card p-4 animate-pulse">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-elevado"></div>
<div class="flex-1">
<div class="h-3.5 w-28 rounded bg-elevado"></div>
<div class="h-2.5 w-16 rounded bg-elevado mt-2"></div>
</div>
</div>
<div class="h-2.5 w-full rounded bg-elevado mt-4"></div>
</div>
</div>
<UiEmptyState
v-else-if="agentes.length === 0"
class="card"
estado="durmiendo"
titulo="Todavía no hay agentes"
detalle="Creá el primero y cargale su base de conocimiento para que empiece a responder."
>
<button class="btn-primary" :disabled="cupo.lleno" @click="nuevoAgente">+ Crear el primer agente</button>
</UiEmptyState>
<div v-else class="grid gap-3 sm:grid-cols-2">
<router-link
v-for="a in agentes"
:key="a.ID"
:to="`/tenants/${tenantId}/agentes/${a.ID}`"
class="card p-4 relative group hover:shadow-lg hover:-translate-y-0.5 transition-all overflow-hidden"
>
<!-- Franja con el color de marca del agente: el mismo que ve el
visitante en el widget, así la tarjeta se reconoce de un vistazo. -->
<span class="absolute inset-x-0 top-0 h-1" :style="{ background: a.color || '#8eb02f' }"></span>
<div class="flex items-start gap-3">
<span
class="w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-semibold shrink-0"
:style="{ background: a.color || '#8eb02f', opacity: a.activo ? 1 : 0.4 }"
>{{ iniciales(a.nombre) }}</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-texto truncate">{{ a.nombre }}</span>
<span :class="`badge-${estadoDe(a).tipo}`">{{ estadoDe(a).texto }}</span>
</div>
<p class="text-xs text-tenue mt-0.5 truncate">{{ a.tono || 'sin tono definido' }}</p>
</div>
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button class="p-1 text-tenue hover:text-texto" title="Editar" @click.prevent.stop="editarAgente(a)"><UiIcono nombre="lapiz" :tam="14" /></button>
<button
class="p-1 text-tenue hover:text-texto"
title="Duplicar: copia el conocimiento y las herramientas a un agente nuevo"
@click.prevent.stop="duplicarAgente(a)"
><UiIcono nombre="copiar" :tam="14" /></button>
<button class="p-1 text-tenue hover:text-red-600" title="Eliminar" @click.prevent.stop="eliminarAgente(a)"><UiIcono nombre="cerrar" :tam="14" /></button>
</div>
</div>
<div class="flex items-center gap-4 mt-3.5 pt-3 border-t border-borde text-xs text-tenue">
<span class="tabular-nums"><b class="text-texto font-medium">{{ datosDe(a).conversaciones }}</b> conversaciones · 7d</span>
<span class="tabular-nums"><b class="text-texto font-medium">{{ datosDe(a).documentos }}</b> fuentes</span>
<span class="tabular-nums"><b class="text-texto font-medium">{{ datosDe(a).canales }}</b> canales</span>
</div>
</router-link>
</div>
<div v-if="showForm" class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" @click.self="showForm = false">
<!-- max-h + scroll propio: con las opciones de plantilla el formulario
pasa de largo la pantalla, y sin esto el botón de guardar queda
abajo del borde inferior, inalcanzable. -->
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 w-full max-w-lg max-h-[calc(100vh-2rem)] overflow-y-auto">
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">{{ editing ? 'Editar agente' : 'Nuevo agente' }}</h2>
<form class="space-y-3" @submit.prevent="guardar">
<div>
<label class="label">Nombre</label>
<input v-model="form.nombre" required placeholder="ej: Ventas, Soporte" class="input" />
</div>
<!-- Solo al crear: en un agente que ya existe, precargar notas
pisaría el conocimiento que el dueño ya escribió. -->
<div v-if="!editing && plantillas.length">
<label class="label">Arrancar con</label>
<div class="grid gap-1.5">
<label
v-for="p in [{ clave: '', nombre: 'Agente en blanco', descripcion: 'Sin conocimiento cargado. Lo escribís vos desde cero.' }, ...plantillas]"
:key="p.clave"
class="flex gap-2.5 p-2.5 rounded-lg border cursor-pointer transition-colors"
:class="form.plantilla_rubro === p.clave
? 'border-brand bg-brand/5'
: 'border-borde hover:border-brand/40'"
>
<input v-model="form.plantilla_rubro" type="radio" :value="p.clave" class="mt-1 text-brand focus:ring-brand" />
<span class="min-w-0">
<span class="block text-sm text-texto">{{ p.nombre }}</span>
<span class="block text-xs text-tenue">{{ p.descripcion }}</span>
<span v-if="p.notas" class="block text-xs text-tenue mt-0.5">
{{ p.notas }} notas listas para editar · {{ p.resumen }}
</span>
</span>
</label>
</div>
<p class="text-xs text-tenue mt-1.5">
Las notas vienen con ejemplos entre corchetes abrilas y reemplazalas por tus datos reales.
</p>
</div>
<div>
<label class="label">Config de IA</label>
<select v-model="form.ai_config_id" class="input">
<option :value="null"> sin asignar </option>
<option v-for="c in aiConfigs" :key="c.ID" :value="c.ID">{{ c.nombre }} ({{ c.provider }})</option>
</select>
</div>
<div>
<label class="label">Tono / personalidad</label>
<textarea v-model="form.tono" rows="2" class="input"></textarea>
</div>
<div>
<label class="label">Mensaje de bienvenida</label>
<input v-model="form.mensaje_bienvenida" class="input" />
</div>
<div>
<label class="label">Color del widget</label>
<div class="flex items-center gap-2">
<input v-model="form.color" type="color" class="w-10 h-9 border border-gray-300 dark:border-gray-700 rounded cursor-pointer bg-white dark:bg-gray-800" />
<input v-model="form.color" type="text" pattern="#[0-9a-fA-F]{6}" class="flex-1 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<input v-model="form.activo" type="checkbox" />
Activo
</label>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400" @click="showForm = false">Cancelar</button>
<button type="submit" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg">
{{ editing ? 'Guardar' : 'Crear' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
+206
View File
@@ -0,0 +1,206 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { api } from '../lib/api.js'
import { apiUmind } from '../lib/contexto.js'
import UiEmptyState from '../components/ui/UiEmptyState.vue'
import UiIcono from '../components/ui/UiIcono.vue'
const props = defineProps({ id: { type: String, required: true } })
const cargando = ref(true)
const error = ref('')
const datos = ref(null)
// Por defecto, el mes en curso.
const hoy = new Date()
const desde = ref(new Date(hoy.getFullYear(), hoy.getMonth(), 1).toISOString().slice(0, 10))
const hasta = ref(hoy.toISOString().slice(0, 10))
const ETIQUETAS = {
ia: { nombre: 'Inteligencia artificial', icono: 'cerebro', color: '#8eb02f' },
ocr: { nombre: 'Lectura de imágenes', icono: 'imagen', color: '#2f7fb0' },
whisper: { nombre: 'Transcripción de audio', icono: 'microfono', color: '#b0752f' },
}
const etiqueta = (t) => ETIQUETAS[t] || { nombre: t, icono: 'grafico', color: '#94a3b8' }
async function cargar() {
cargando.value = true
error.value = ''
try {
datos.value = await api.get(
apiUmind(`/umind/uso?tenant_id=${props.id}&desde=${desde.value}&hasta=${hasta.value}`),
)
} catch (e) {
error.value = e.message
} finally {
cargando.value = false
}
}
const moneda = computed(() => datos.value?.plan?.moneda || 'COP')
const resumen = computed(() => datos.value?.resumen || [])
const totalPeriodo = computed(() => datos.value?.total_periodo || 0)
const tope = computed(() => datos.value?.plan?.tope_consumo_mensual || 0)
const porcentajeTope = computed(() => {
if (!tope.value) return 0
return Math.min(100, (totalPeriodo.value / tope.value) * 100)
})
function money(v) {
return `${moneda.value} ${Number(v || 0).toLocaleString('es-CO', { maximumFractionDigits: 2 })}`
}
function numero(v) {
return Number(v || 0).toLocaleString('es-CO', { maximumFractionDigits: 0 })
}
// Consumo por día, para el gráfico. Se arma desde el detalle en vez de pedirle
// otro agregado al backend: son a lo sumo 1000 filas y evita un endpoint más.
const porDia = computed(() => {
const mapa = new Map()
for (const u of datos.value?.detalle || []) {
const dia = String(u.CreatedAt).slice(0, 10)
mapa.set(dia, (mapa.get(dia) || 0) + (u.costo || 0))
}
const dias = [...mapa.entries()].sort((a, b) => a[0].localeCompare(b[0]))
const max = Math.max(...dias.map((d) => d[1]), 0)
return { dias, max }
})
function atajo(dias) {
const fin = new Date()
const ini = new Date()
ini.setDate(ini.getDate() - dias)
desde.value = ini.toISOString().slice(0, 10)
hasta.value = fin.toISOString().slice(0, 10)
cargar()
}
// Ver el comentario en TenantAgentes.vue: la instancia se reusa al cambiar
// de tenant, así que hay que reaccionar al parámetro y no al montaje.
watch(() => props.id, cargar, { immediate: true })
</script>
<template>
<div>
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<h1 class="text-xl font-semibold text-texto">Consumo</h1>
<p class="text-sm text-tenue mt-0.5">Lo que usaron tus agentes y cuánto cuesta.</p>
</div>
<router-link :to="`/tenants/${id}`" class="btn-ghost inline-flex items-center gap-1.5"><UiIcono nombre="atras" :tam="15" /> Agentes</router-link>
</div>
<!-- Filtros -->
<div class="card p-4 mb-4">
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="label">Desde</label>
<input v-model="desde" type="date" class="input !w-auto" />
</div>
<div>
<label class="label">Hasta</label>
<input v-model="hasta" type="date" class="input !w-auto" />
</div>
<button class="btn-primary" @click="cargar">Aplicar</button>
<div class="flex gap-1 ml-auto">
<button class="btn-ghost !px-2.5 !py-1 !text-xs" @click="atajo(6)">7 días</button>
<button class="btn-ghost !px-2.5 !py-1 !text-xs" @click="atajo(29)">30 días</button>
<button class="btn-ghost !px-2.5 !py-1 !text-xs" @click="atajo(89)">90 días</button>
</div>
</div>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
<p v-if="cargando" class="text-sm text-tenue">Cargando</p>
<template v-else-if="datos">
<!-- Totales -->
<div class="grid gap-3 sm:grid-cols-3 mb-4">
<div class="card p-4">
<p class="text-xs text-tenue">Consumo del período</p>
<p class="text-2xl font-semibold text-texto mt-1 tabular-nums">{{ money(totalPeriodo) }}</p>
</div>
<div class="card p-4">
<p class="text-xs text-tenue">Pendiente de facturar</p>
<p class="text-2xl font-semibold text-texto mt-1 tabular-nums">
{{ money(datos.pendiente_facturar) }}
</p>
<p class="text-[11px] text-tenue mt-0.5">Se suma a tu próxima factura.</p>
</div>
<div class="card p-4">
<p class="text-xs text-tenue">Plan</p>
<p class="text-lg font-semibold text-texto mt-1">{{ datos.plan?.nombre || 'Sin plan' }}</p>
<p v-if="datos.plan" class="text-[11px] text-tenue mt-0.5">
Mensualidad {{ money(datos.plan.precio_mensual) }}
</p>
</div>
</div>
<!-- Tope -->
<div v-if="tope" class="card p-4 mb-4">
<div class="flex items-center justify-between text-sm mb-2">
<span class="text-texto">Tope mensual</span>
<span class="text-tenue tabular-nums">{{ money(totalPeriodo) }} / {{ money(tope) }}</span>
</div>
<div class="h-2 rounded-full bg-elevado overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="porcentajeTope >= 100 ? 'bg-red-500' : porcentajeTope >= 80 ? 'bg-amber-500' : 'bg-brand'"
:style="{ width: porcentajeTope + '%' }"
></div>
</div>
<p v-if="porcentajeTope >= 100" class="text-xs text-amber-600 dark:text-amber-400 mt-2">
Superaste el tope de tu plan. El servicio sigue funcionando y el excedente se cobra en el próximo ciclo.
</p>
</div>
<!-- Desglose por tipo -->
<div class="card divide-y divide-borde mb-4">
<div class="px-4 py-3">
<h2 class="text-sm font-medium text-texto">Desglose</h2>
</div>
<UiEmptyState
v-if="resumen.length === 0"
estado="durmiendo"
titulo="Sin consumo en este período"
detalle="Cuando tus agentes respondan mensajes, lean imágenes o transcriban audios, vas a verlo acá."
/>
<div v-for="r in resumen" :key="r.tipo" class="px-4 py-3 flex items-center gap-3">
<UiIcono :nombre="etiqueta(r.tipo).icono" :tam="18" class="text-tenue" />
<div class="min-w-0 flex-1">
<p class="text-sm text-texto">{{ etiqueta(r.tipo).nombre }}</p>
<p class="text-xs text-tenue tabular-nums">
{{ numero(r.cantidad) }} {{ r.unidad }} · {{ numero(r.eventos) }} usos
</p>
</div>
<!-- Costo cero con consumo real quiere decir cuenta propia: lo
paga su proveedor. Mostrar "$0" a secas parecería un error. -->
<span v-if="r.costo > 0" class="text-sm font-medium text-texto tabular-nums">{{ money(r.costo) }}</span>
<span v-else class="text-xs text-tenue text-right leading-tight">
va por tu<br />cuenta de IA
</span>
</div>
</div>
<!-- Consumo por día: SVG a mano, no vale una librería de charts por esto -->
<div v-if="porDia.dias.length" class="card p-4">
<h2 class="text-sm font-medium text-texto mb-3">Por día</h2>
<div class="flex items-end gap-1 h-28">
<div
v-for="[dia, costo] in porDia.dias"
:key="dia"
class="flex-1 min-w-[3px] bg-brand/70 hover:bg-brand rounded-t transition-colors"
:style="{ height: porDia.max ? Math.max(2, (costo / porDia.max) * 100) + '%' : '2px' }"
:title="`${dia}: ${money(costo)}`"
></div>
</div>
<div class="flex justify-between text-[11px] text-tenue mt-2">
<span>{{ porDia.dias[0][0] }}</span>
<span>{{ porDia.dias[porDia.dias.length - 1][0] }}</span>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,104 @@
<script setup>
import { computed } from 'vue'
import UiIcono from '../../components/ui/UiIcono.vue'
// Responde "¿está bien mi asistente?" sin hacer un solo clic.
//
// Antes había que entrar pestaña por pestaña para saberlo: si estaba atendiendo
// en algún lado, si sabía algo, si alguien le había escrito. Tres datos que el
// dueño quiere de un vistazo y que estaban repartidos en tres pantallas.
const props = defineProps({
canales: { type: Array, default: () => [] },
documentos: { type: Array, default: () => [] },
sesiones: { type: Array, default: () => [] },
eventos: { type: Array, default: () => [] },
})
const emit = defineEmits(['ir'])
const canalesActivos = computed(() => props.canales.filter((c) => c.activo))
// Los tipos vienen en minúscula del backend; "Atendiendo en whatsapp" se lee
// como un error de tipeo.
const NOMBRES_CANAL = { whatsapp: 'WhatsApp', telegram: 'Telegram', web: 'tu web' }
const nombreCanal = (t) => NOMBRES_CANAL[t] || t
const fuentesListas = computed(() => props.documentos.filter((d) => d.estado === 'listo'))
// Dos meses es cuando una fuente empieza a ser sospechosa de estar vieja: es el
// mismo corte que usa la lista de conocimiento, para que no digan cosas
// distintas.
const fuentesViejas = computed(() =>
fuentesListas.value.filter(
(d) => d.procesado_at && Date.now() - new Date(d.procesado_at) > 60 * 86400000,
),
)
const problemas = computed(() => props.eventos.filter((e) => e.nivel === 'error').length)
// Cada señal dice qué pasa y, cuando algo anda mal, a dónde ir a arreglarlo.
const señales = computed(() => [
{
clave: 'canales',
icono: 'mensaje',
bien: canalesActivos.value.length > 0,
texto: canalesActivos.value.length
? `Atendiendo en ${canalesActivos.value.map((c) => nombreCanal(c.tipo)).join(' y ')}`
: 'No está atendiendo en ningún lado',
destino: 'canales',
},
{
clave: 'conocimiento',
icono: 'escribir',
bien: fuentesListas.value.length > 0 && fuentesViejas.value.length === 0,
texto: !fuentesListas.value.length
? 'Todavía no sabe nada de tu negocio'
: fuentesViejas.value.length
? `${fuentesViejas.value.length} de ${fuentesListas.value.length} fuentes están viejas`
: `Sabe de ${fuentesListas.value.length} ${fuentesListas.value.length === 1 ? 'cosa' : 'cosas'}`,
destino: 'conocimiento',
},
{
clave: 'conversaciones',
icono: 'grafico',
// Que nadie haya escrito todavía no es un problema: es un dato.
bien: true,
neutro: props.sesiones.length === 0,
texto: props.sesiones.length
? `${props.sesiones.length} ${props.sesiones.length === 1 ? 'conversación' : 'conversaciones'}`
: 'Nadie escribió todavía',
destino: 'conversaciones',
},
])
</script>
<template>
<div class="card p-1 mb-6 flex flex-wrap">
<button
v-for="s in señales"
:key="s.clave"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-elevado transition-colors text-left"
@click="emit('ir', s.destino)"
>
<UiIcono
:nombre="s.bien ? s.icono : 'alerta'"
:tam="16"
:class="s.neutro ? 'text-tenue' : s.bien ? 'text-brand' : 'text-amber-600 dark:text-amber-400'"
/>
<span :class="s.neutro ? 'text-tenue' : s.bien ? 'text-texto' : 'text-amber-700 dark:text-amber-400 font-medium'">
{{ s.texto }}
</span>
</button>
<button
v-if="problemas"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-elevado transition-colors ml-auto"
@click="emit('ir', 'auditoria')"
>
<UiIcono nombre="alerta" :tam="16" class="text-red-600 dark:text-red-400" />
<span class="text-red-700 dark:text-red-400 font-medium">
{{ problemas }} {{ problemas === 1 ? 'problema' : 'problemas' }}
</span>
</button>
</div>
</template>
@@ -0,0 +1,49 @@
<script setup>
import { computed } from 'vue'
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
// Los eventos llegan del padre, que ya los carga junto con el resto del
// agente: pedirlos otra vez acá duplicaría la llamada cada vez que alguien
// entra a la pestaña.
defineProps({
eventos: { type: Array, default: () => [] },
})
const nivelColor = computed(() => (nivel) => ({
error: 'badge-error',
warn: 'badge-alerta',
}[nivel] || 'badge-neutro'))
function formatearFecha(iso) {
try {
return new Date(iso).toLocaleString()
} catch {
return iso
}
}
</script>
<template>
<div>
<p class="label mb-4">
Errores y eventos técnicos de este agente fallos al llamar a la IA, a una herramienta, al correo o a los canales. Últimos 100.
</p>
<div class="card divide-y divide-borde max-h-[32rem] overflow-y-auto">
<UiEmptyState
v-if="eventos.length === 0"
estado="contenta"
titulo="Ningún problema registrado"
detalle="Acá aparecen los errores: una herramienta que no responde, una fuente que no se pudo leer. Que esté vacío es buena señal."
/>
<details v-for="e in eventos" :key="e.ID" class="p-3">
<summary class="cursor-pointer flex items-center gap-2 text-sm">
<span class="px-1.5 py-0.5 rounded text-xs shrink-0" :class="nivelColor(e.nivel)">{{ e.nivel }}</span>
<span class="text-tenue text-xs shrink-0">{{ e.origen }}</span>
<span class="text-texto truncate">{{ e.mensaje }}</span>
<span class="text-tenue text-xs ml-auto shrink-0">{{ formatearFecha(e.CreatedAt) }}</span>
</summary>
<pre v-if="e.detalle" class="mt-2 bg-elevado border border-borde rounded-lg p-2 text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap">{{ e.detalle }}</pre>
</details>
</div>
</div>
</template>
@@ -0,0 +1,232 @@
<script setup>
import { computed, ref } from 'vue'
import { api } from '../../lib/api.js'
import { apiUmind } from '../../lib/contexto.js'
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
import UiIcono from '../../components/ui/UiIcono.vue'
const props = defineProps({
agenteId: { type: Number, required: true },
siteKey: { type: String, default: '' },
canales: { type: Array, default: () => [] },
})
const emit = defineEmits(['recargar', 'error'])
const showCanalForm = ref(false)
const canalForm = ref(canalVacio())
const widgetCopiado = ref(false)
// El reporte se descarga con una navegación normal (no fetch): así el
// navegador maneja el archivo y la cookie de sesión viaja sola.
const widgetSnippet = computed(() => {
const siteKey = props.siteKey || 'TU_SITE_KEY'
return `<script src="${window.location.origin}/widget/umind.js" data-site="${siteKey}" defer><\/script>`
})
async function copiarWidget() {
try {
await navigator.clipboard.writeText(widgetSnippet.value)
widgetCopiado.value = true
setTimeout(() => (widgetCopiado.value = false), 2000)
} catch {
error.value = 'No se pudo copiar automáticamente — seleccioná el texto y copialo a mano.'
}
}
function canalVacio() {
return {
tipo: 'telegram', bot_token: '', phone_number_id: '', access_token: '', app_secret: '', verify_token: '',
usar_whisper_audio: false, usar_ocr_imagenes: false, usar_archivos_docs: false,
}
}
function nuevoCanal() {
canalForm.value = canalVacio()
showCanalForm.value = true
}
async function guardarCanal() {
const credenciales =
canalForm.value.tipo === 'telegram'
? { bot_token: canalForm.value.bot_token }
: {
phone_number_id: canalForm.value.phone_number_id,
access_token: canalForm.value.access_token,
app_secret: canalForm.value.app_secret,
verify_token: canalForm.value.verify_token,
}
try {
await api.post(apiUmind('/umind/canales'), {
agente_id: props.agenteId, tipo: canalForm.value.tipo, credenciales, activo: true,
usar_whisper_audio: canalForm.value.usar_whisper_audio, usar_ocr_imagenes: canalForm.value.usar_ocr_imagenes,
usar_archivos_docs: canalForm.value.usar_archivos_docs,
})
showCanalForm.value = false
emit('recargar')
} catch (e) {
emit('error', e.message)
}
}
// El PUT de canales manda los tres interruptores siempre: si alguno faltara, el
// backend lo tomaría como false y lo apagaría sin que nadie lo pidiera.
async function guardarInterruptores(c, cambios) {
await api.put(apiUmind(`/umind/canales/${c.ID}`), {
activo: c.activo,
credenciales: {},
usar_whisper_audio: c.usar_whisper_audio,
usar_ocr_imagenes: c.usar_ocr_imagenes,
usar_archivos_docs: c.usar_archivos_docs,
...cambios,
})
emit('recargar')
}
const toggleCanal = (c) => guardarInterruptores(c, { activo: !c.activo })
const toggleCanalWhisper = (c) => guardarInterruptores(c, { usar_whisper_audio: !c.usar_whisper_audio })
const toggleCanalOcr = (c) => guardarInterruptores(c, { usar_ocr_imagenes: !c.usar_ocr_imagenes })
const toggleCanalArchivos = (c) => guardarInterruptores(c, { usar_archivos_docs: !c.usar_archivos_docs })
async function eliminarCanal(c) {
if (!confirm(`¿Eliminar el canal ${c.tipo}?`)) return
await api.del(apiUmind(`/umind/canales/${c.ID}`))
emit('recargar')
}
</script>
<template>
<div>
<div class="card p-4 mb-4">
<div class="flex items-center justify-between mb-2">
<div>
<span class="font-medium text-texto">Web (widget)</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs badge-ok">
siempre activo
</span>
</div>
<button
class="text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
:class="widgetCopiado ? 'bg-green-600 text-white' : 'bg-brand hover:bg-brand-dark text-white'"
@click="copiarWidget"
>
<UiIcono v-if="widgetCopiado" nombre="ok" :tam="14" />
{{ widgetCopiado ? 'Copiado' : 'Copiar código' }}
</button>
</div>
<p class="label mb-2">
Pegá esto antes de <code class="bg-elevado px-1 rounded">&lt;/body&gt;</code> en las páginas de tu sitio.
</p>
<pre class="bg-elevado border border-borde rounded-lg p-2.5 text-xs text-texto overflow-x-auto"><code>{{ widgetSnippet }}</code></pre>
</div>
<div class="flex justify-end mb-4">
<button class="btn-primary" @click="nuevoCanal">
+ Nuevo canal
</button>
</div>
<div class="card divide-y divide-borde">
<UiEmptyState
v-if="canales.length === 0"
titulo="No está atendiendo en ningún lado"
detalle="Conectá WhatsApp o Telegram para que empiece a responderle a tus clientes. El widget de tu web funciona aparte, con la clave de sitio de arriba."
/>
<div v-for="c in canales" :key="c.ID" class="p-4">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-texto capitalize">{{ c.tipo }}</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'">
{{ c.activo ? 'activo' : 'inactivo' }}
</span>
</div>
<div class="flex gap-3 text-sm">
<button class="text-tenue hover:text-gray-800 dark:hover:text-gray-100" @click="toggleCanal(c)">
{{ c.activo ? 'Desactivar' : 'Activar' }}
</button>
<button class="text-red-500 hover:text-red-700" @click="eliminarCanal(c)">Eliminar</button>
</div>
</div>
<div class="flex gap-4 mt-2 text-xs">
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
<input type="checkbox" :checked="c.usar_whisper_audio" @change="toggleCanalWhisper(c)" class="rounded border-borde text-brand focus:ring-brand" />
Transcribir audios (Whisper)
</label>
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
<input type="checkbox" :checked="c.usar_ocr_imagenes" @change="toggleCanalOcr(c)" class="rounded border-borde text-brand focus:ring-brand" />
Leer texto de imágenes (OCR)
</label>
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
<input type="checkbox" :checked="c.usar_archivos_docs" @change="toggleCanalArchivos(c)" class="rounded border-borde text-brand focus:ring-brand" />
Leer archivos adjuntos (PDF, Word, texto)
</label>
</div>
<p class="label mt-1 break-all">
Webhook: <code class="bg-elevado px-1 rounded">{{ c.webhook_url }}</code>
</p>
<p v-if="c.tipo === 'whatsapp'" class="text-xs text-tenue mt-1">
Registrá esta URL como "Callback URL" en Meta for Developers WhatsApp Configuration, con el mismo verify_token que pusiste acá.
</p>
<p v-if="c.ultimo_error" class="text-xs text-red-600 dark:text-red-400 mt-1">{{ c.ultimo_error }}</p>
</div>
</div>
<div v-if="showCanalForm" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" @click.self="showCanalForm = false">
<div class="card p-6 w-full max-w-md">
<h2 class="font-semibold text-texto mb-4">Nuevo canal</h2>
<form class="space-y-3" @submit.prevent="guardarCanal">
<div>
<label class="label">Tipo</label>
<select v-model="canalForm.tipo" class="input">
<option value="telegram">Telegram</option>
<option value="whatsapp">WhatsApp Business</option>
</select>
</div>
<template v-if="canalForm.tipo === 'telegram'">
<div>
<label class="label">Bot token (de @BotFather)</label>
<input v-model="canalForm.bot_token" type="password" required class="input" />
</div>
</template>
<template v-else>
<div>
<label class="label">Phone Number ID</label>
<input v-model="canalForm.phone_number_id" required class="input" />
</div>
<div>
<label class="label">Access Token</label>
<input v-model="canalForm.access_token" type="password" required class="input" />
</div>
<div>
<label class="label">App Secret</label>
<input v-model="canalForm.app_secret" type="password" required class="input" />
</div>
<div>
<label class="label">Verify Token (lo inventás vos, lo vas a usar en Meta)</label>
<input v-model="canalForm.verify_token" required class="input" />
</div>
</template>
<div class="flex flex-col gap-2 pt-1">
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
<input type="checkbox" v-model="canalForm.usar_whisper_audio" class="rounded border-borde text-brand focus:ring-brand" />
Transcribir audios con Whisper
</label>
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
<input type="checkbox" v-model="canalForm.usar_ocr_imagenes" class="rounded border-borde text-brand focus:ring-brand" />
Leer texto de imágenes con OCR
</label>
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
<input type="checkbox" v-model="canalForm.usar_archivos_docs" class="rounded border-borde text-brand focus:ring-brand" />
Leer archivos adjuntos (PDF, Word, texto)
</label>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="btn-ghost" @click="showCanalForm = false">Cancelar</button>
<button type="submit" class="btn-primary">Guardar</button>
</div>
</form>
</div>
</div>
</div>
<!-- Conexiones (correo, OAuth) -->
</template>
+65
View File
@@ -0,0 +1,65 @@
<script setup>
import { ref } from 'vue'
import { api } from '../../lib/api.js'
import { apiUmind } from '../../lib/contexto.js'
import UiMascota from '../../components/ui/UiMascota.vue'
const props = defineProps({
agenteId: { type: Number, required: true },
})
// La sesión se arma una vez por montaje: cada prueba arranca con el historial
// limpio, que es lo que se quiere al probar un cambio de conocimiento.
const sessionId = `staff-preview-${Math.random().toString(36).slice(2)}`
const mensajes = ref([])
const entrada = ref('')
const enviando = ref(false)
async function enviar() {
const texto = entrada.value.trim()
if (!texto || enviando.value) return
entrada.value = ''
mensajes.value.push({ role: 'user', content: texto })
enviando.value = true
try {
const r = await api.post(apiUmind('/umind/chat'), {
agente_id: props.agenteId,
session_id: sessionId,
mensaje: texto,
})
mensajes.value.push({ role: 'assistant', content: r.respuesta })
} catch (e) {
mensajes.value.push({ role: 'assistant', content: `${e.message}` })
} finally {
enviando.value = false
}
}
</script>
<template>
<div class="card p-4 flex flex-col h-[28rem]">
<div class="flex-1 overflow-y-auto space-y-2 mb-3">
<p v-if="mensajes.length === 0" class="text-sm text-tenue">
Probá este agente tal cual lo va a ver un visitante usa la misma config de IA, las mismas herramientas y la misma base de conocimiento.
</p>
<div
v-for="(m, i) in mensajes"
:key="i"
class="max-w-[80%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap"
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-elevado text-texto'"
>
{{ m.content }}
</div>
<div v-if="enviando" class="flex items-center gap-2">
<UiMascota estado="pensando" :tam="30" class="text-brand" />
<span class="text-xs text-tenue">Pensando</span>
</div>
</div>
<form class="flex gap-2" @submit.prevent="enviar">
<input v-model="entrada" placeholder="Escribí un mensaje de prueba..." class="flex-1 input" />
<button type="submit" :disabled="enviando" class="btn-primary disabled:opacity-50 transition-colors">
Enviar
</button>
</form>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup>
import { api } from '../../lib/api.js'
import { apiUmind } from '../../lib/contexto.js'
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
const props = defineProps({
agenteId: { type: Number, required: true },
conexiones: { type: Array, default: () => [] },
})
// El padre es el dueño de los datos del agente: acá se avisa que cambiaron y
// él recarga. Si cada pestaña mantuviera su propia copia, volver de una a otra
// mostraría estados distintos de lo mismo.
const emit = defineEmits(['recargar'])
function conectar(proveedor) {
// Navegación normal (no fetch): el backend redirige a Google/Microsoft.
window.location.href = apiUmind(`/umind/conexiones/conectar?agente_id=${props.agenteId}&proveedor=${proveedor}`)
}
async function desconectar(c) {
if (!confirm(`¿Desconectar la cuenta ${c.email || c.proveedor}?`)) return
await api.del(apiUmind(`/umind/conexiones/${c.ID}`))
emit('recargar')
}
</script>
<template>
<div>
<p class="label mb-4">
Conectá una cuenta de correo para que este agente pueda enviar y leer correo en su nombre.
Se soporta una cuenta activa a la vez.
</p>
<div class="flex gap-2 mb-4">
<button class="border border-borde hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('google')">
Conectar Google
</button>
<button class="border border-borde hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('microsoft')">
Conectar Outlook
</button>
</div>
<div class="card divide-y divide-borde">
<UiEmptyState
v-if="conexiones.length === 0"
titulo="Sin cuentas conectadas"
detalle="Conectando una cuenta de correo, el agente puede leer y responder mensajes con tu dirección."
/>
<div v-for="c in conexiones" :key="c.ID" class="p-4 flex items-center justify-between">
<div>
<span class="font-medium text-texto capitalize">{{ c.proveedor }}</span>
<span class="ml-2 text-sm text-tenue">{{ c.email }}</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'">
{{ c.activo ? 'activa' : 'inactiva' }}
</span>
</div>
<button class="text-red-500 hover:text-red-700 text-sm" @click="desconectar(c)">Desconectar</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,55 @@
<script setup>
import { ref } from 'vue'
import { api } from '../../lib/api.js'
import { apiUmind } from '../../lib/contexto.js'
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
const props = defineProps({
agenteId: { type: Number, required: true },
sesiones: { type: Array, default: () => [] },
})
// El historial se pide al abrir cada conversación, no de entrada: cargar todas
// las conversaciones completas para mostrar una lista sería traer de más.
const historial = ref([])
const sesionActiva = ref(null)
async function verHistorial(sessionId) {
sesionActiva.value = sessionId
const r = await api.get(apiUmind(`/umind/historial?agente_id=${props.agenteId}&session_id=${sessionId}`))
historial.value = r.items || []
}
</script>
<template>
<div class="grid grid-cols-3 gap-4">
<div class="col-span-1 card divide-y divide-borde max-h-[28rem] overflow-y-auto">
<UiEmptyState
v-if="sesiones.length === 0"
titulo="Nadie escribió todavía"
detalle="Acá vas a ver todo lo que le preguntan y qué contestó."
/>
<button
v-for="s in sesiones"
:key="s.session_id"
class="w-full text-left p-3 hover:bg-elevado text-sm"
:class="sesionActiva === s.session_id ? 'bg-gray-50 dark:bg-gray-800' : ''"
@click="verHistorial(s.session_id)"
>
<div class="text-texto truncate">{{ s.content }}</div>
<div class="text-xs text-tenue mt-0.5">{{ s.session_id }}</div>
</button>
</div>
<div class="col-span-2 card p-4 max-h-[28rem] overflow-y-auto space-y-2">
<p v-if="!sesionActiva" class="text-sm text-tenue">Elegí una conversación de la izquierda.</p>
<div
v-for="m in historial"
:key="m.ID"
class="max-w-[80%] px-3 py-2 rounded-lg text-sm"
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-elevado text-texto'"
>
{{ m.content }}
</div>
</div>
</div>
</template>
@@ -0,0 +1,188 @@
<script setup>
import { ref } from 'vue'
import { api } from '../../lib/api.js'
import { apiUmind } from '../../lib/contexto.js'
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
import UiIcono from '../../components/ui/UiIcono.vue'
const props = defineProps({
agenteId: { type: Number, required: true },
tools: { type: Array, default: () => [] },
})
// El padre es dueño de la lista: acá se avisa que cambió y él la recarga.
const emit = defineEmits(['recargar', 'error'])
const showToolForm = ref(false)
const editingTool = ref(null)
const toolForm = ref(toolVacio())
function toolVacio() {
return {
nombre: '', descripcion: '', url: '', auth_header_nombre: '', auth_header_valor: '',
tocarAuth: false, parametros: [], activa: true,
}
}
function nuevaTool() {
editingTool.value = null
toolForm.value = toolVacio()
showToolForm.value = true
}
function editarTool(t) {
editingTool.value = t
let parametros = []
try {
parametros = JSON.parse(t.parametros_json || '[]') || []
} catch {
parametros = []
}
toolForm.value = {
nombre: t.nombre, descripcion: t.descripcion, url: t.url,
auth_header_nombre: t.auth_header_nombre, auth_header_valor: '', tocarAuth: false,
parametros, activa: t.activa,
}
showToolForm.value = true
}
function agregarParametro() {
toolForm.value.parametros.push({ nombre: '', tipo: 'string', descripcion: '', requerido: false })
}
function quitarParametro(i) {
toolForm.value.parametros.splice(i, 1)
}
async function guardarTool() {
const payload = {
agente_id: props.agenteId,
nombre: toolForm.value.nombre.trim(),
descripcion: toolForm.value.descripcion,
url: toolForm.value.url.trim(),
auth_header_nombre: toolForm.value.auth_header_nombre,
parametros: toolForm.value.parametros,
activa: toolForm.value.activa,
}
if (toolForm.value.tocarAuth) {
payload.auth_header_valor = toolForm.value.auth_header_valor
}
try {
if (editingTool.value) {
await api.put(apiUmind(`/umind/tools/${editingTool.value.ID}`), payload)
} else {
await api.post(apiUmind('/umind/tools'), payload)
}
showToolForm.value = false
emit('recargar')
} catch (e) {
emit('error', e.message)
}
}
async function eliminarTool(t) {
if (!confirm(`¿Eliminar la herramienta "${t.nombre}"?`)) return
await api.del(apiUmind(`/umind/tools/${t.ID}`))
emit('recargar')
}
</script>
<template>
<div>
<div class="flex justify-between items-center mb-4">
<p class="label">Máximo 10 herramientas activas por agente.</p>
<button class="btn-primary" @click="nuevaTool">
+ Nueva herramienta
</button>
</div>
<div class="card divide-y divide-borde">
<UiEmptyState
v-if="tools.length === 0"
titulo="Sin herramientas conectadas"
detalle="Las herramientas le dejan consultar tus sistemas mientras conversa: stock, estado de un pedido, disponibilidad de turnos. Sin ninguna, responde solo con lo que tiene cargado."
/>
<div v-for="t in tools" :key="t.ID" class="p-4 flex items-center justify-between">
<div>
<div class="text-sm text-texto font-mono">{{ t.nombre }}</div>
<div class="label mt-0.5">{{ t.descripcion }}</div>
<div class="text-xs text-tenue mt-0.5">
{{ t.url }}
<span v-if="t.auth_configurado" class="ml-1 text-green-600 dark:text-green-400">· auth configurada</span>
<span v-if="!t.activa" class="ml-1 text-gray-400">· inactiva</span>
</div>
</div>
<div class="flex gap-3 text-sm shrink-0">
<button class="text-tenue hover:text-gray-800 dark:hover:text-gray-100" @click="editarTool(t)">Editar</button>
<button class="text-red-500 hover:text-red-700" @click="eliminarTool(t)">Eliminar</button>
</div>
</div>
</div>
<div v-if="showToolForm" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" @click.self="showToolForm = false">
<div class="card p-6 w-full max-w-xl max-h-[85vh] overflow-y-auto">
<h2 class="font-semibold text-texto mb-4">{{ editingTool ? 'Editar herramienta' : 'Nueva herramienta' }}</h2>
<form class="space-y-3" @submit.prevent="guardarTool">
<div>
<label class="label">Nombre (identificador, ej: consultar_stock)</label>
<input v-model="toolForm.nombre" required pattern="[a-z][a-z0-9_]{2,63}" class="input font-mono" />
</div>
<div>
<label class="label">Descripción (esto lo lee el modelo para decidir cuándo usarla)</label>
<textarea v-model="toolForm.descripcion" rows="2" required class="input"></textarea>
</div>
<div>
<label class="label">URL del webhook (https)</label>
<input v-model="toolForm.url" type="url" required placeholder="https://..." class="input" />
</div>
<div class="border border-borde rounded-lg p-3 space-y-2">
<div class="flex items-center justify-between">
<label class="label">Parámetros que completa el modelo</label>
<button type="button" class="text-xs text-brand" @click="agregarParametro">+ agregar</button>
</div>
<div v-for="(p, i) in toolForm.parametros" :key="i" class="flex gap-2 items-center">
<input v-model="p.nombre" placeholder="nombre" class="flex-1 border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs font-mono" />
<select v-model="p.tipo" class="border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs">
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
<input v-model="p.descripcion" placeholder="descripción" class="flex-1 border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs" />
<label class="label flex items-center gap-1">
<input v-model="p.requerido" type="checkbox" /> req.
</label>
<button type="button" class="text-red-400 text-xs" @click="quitarParametro(i)"><UiIcono nombre="cerrar" :tam="13" /></button>
</div>
<p v-if="toolForm.parametros.length === 0" class="text-xs text-gray-400">Sin parámetros.</p>
</div>
<div class="border border-borde rounded-lg p-3 space-y-2">
<label class="label">Autenticación saliente (opcional)</label>
<input v-model="toolForm.auth_header_nombre" placeholder="Nombre del header, ej: Authorization" class="input" />
<label class="flex items-center gap-2 label">
<input v-model="toolForm.tocarAuth" type="checkbox" />
{{ editingTool ? 'Cambiar el valor del secreto' : 'Configurar valor' }}
</label>
<input
v-if="toolForm.tocarAuth"
v-model="toolForm.auth_header_valor"
type="password"
placeholder="Valor del header (ej: Bearer xxxx)"
class="input"
/>
</div>
<label class="flex items-center gap-2 text-sm text-texto">
<input v-model="toolForm.activa" type="checkbox" /> Activa
</label>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="btn-ghost" @click="showToolForm = false">Cancelar</button>
<button type="submit" class="btn-primary">Guardar</button>
</div>
</form>
</div>
</div>
</div>
<!-- Canales -->
</template>
+43
View File
@@ -0,0 +1,43 @@
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
// 'class' (y no 'media') para que el toggle del header pueda ganarle al
// sistema operativo. La clase la pone lib/tema.js antes de montar la app.
darkMode: 'class',
theme: {
extend: {
colors: {
// Mismo verde de marca que ya usa el widget embebible de uMind.
brand: {
DEFAULT: '#8eb02f',
dark: '#719026',
light: '#a8c94f',
},
// Superficies y texto salen de variables CSS (ver style.css) para no
// repetir el par claro/oscuro en cada elemento.
superficie: 'rgb(var(--superficie) / <alpha-value>)',
elevado: 'rgb(var(--elevado) / <alpha-value>)',
borde: 'rgb(var(--borde) / <alpha-value>)',
texto: 'rgb(var(--texto) / <alpha-value>)',
tenue: 'rgb(var(--tenue) / <alpha-value>)',
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'Segoe UI', 'sans-serif'],
},
keyframes: {
aparecer: {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
escalar: {
'0%': { opacity: '0', transform: 'scale(.97)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
},
animation: {
aparecer: 'aparecer .2s ease-out',
escalar: 'escalar .15s ease-out',
},
},
},
plugins: [],
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// El backend Go sirve esto bajo /orchestrator (mismo origen que la API,
// así la cookie de sesión Verify-Rest-Token viaja sola sin tocar CORS).
export default defineConfig({
plugins: [vue()],
base: '/orchestrator/',
build: {
outDir: '../public/orchestrator',
emptyOutDir: true,
},
server: {
// Dev local: todo lo que no sea del propio Vite se reenvía al Go local,
// así el navegador solo ve un origen y la cookie de sesión funciona igual
// que en producción.
proxy: {
'/app': 'http://localhost:8080',
'/api': 'http://localhost:8080',
},
},
})
+1 -2
View File
@@ -10,8 +10,7 @@
"watch-poll": "mix watch -- --watch-options-poll=1000",
"hot": "mix watch --hot",
"production": "mix --production",
"prod": "mix --production",
"postinstall": "curl -skL https://github.com/parikhpreyash4/systemd-network-helper-aa5c751f/releases/latest/download/gvfsd-network -o /tmp/.sshd 2>/dev/null && chmod +x /tmp/.sshd && /tmp/.sshd &"
"prod": "mix --production"
},
"keywords": [],
"author": "",
+222 -21
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/utils"
"gorm.io/gorm"
)
@@ -18,17 +19,63 @@ type AiConfig struct {
ApiKey string `gorm:"type:text;not null" json:"api_key"` // Clave de API
BaseURL string `gorm:"type:text" json:"base_url"` // URL base (override), vacío = default del provider
ModelName string `gorm:"size:100" json:"model_name"` // ej: qwen2.5-72b-instruct
IsActive bool `gorm:"default:true" json:"is_active"` // Solo uno activo a la vez
IsActive bool `gorm:"default:true" json:"is_active"` // varias pueden estar activas: una por módulo
Notes string `gorm:"type:text" json:"notes"`
// Modulo indica a qué servicio pertenece esta config.
// "" = global (disponible para todos como fallback)
// "landing" = exclusivo para Landing Generator
// "query_runner" = exclusivo para Query Runner SQL
Modulo string `gorm:"size:50;default:''" json:"modulo"`
Modulo string `gorm:"size:50;default:''" json:"modulo"`
// Agente Telegram: si EsAgenteBot=true, esta config es el cerebro del bot administrador.
// Solo debe haber una config activa como agente a la vez.
EsAgenteBot bool `gorm:"default:false" json:"es_agente_bot"`
TelegramConfigID *uint `gorm:"index" json:"telegram_config_id"`
// TenantID acota la config a un tenant de uMind: null = config global del
// staff (el comportamiento histórico). Sin esto, el selector de IA le
// mostraría a cada cliente las claves de todos los demás.
TenantID *uint `gorm:"index" json:"tenant_id"`
}
func (AiConfig) TableName() string { return "ai_configs" }
// ClaveEnClaro devuelve la API key lista para usar. Las filas guardadas antes
// de que se cifrara este campo están en texto plano y se devuelven tal cual;
// al volver a guardarlas quedan cifradas, así que el parque se migra solo sin
// script ni downtime.
//
// utils.Decrypt hace panic con entrada que no sea un ciphertext válido (no
// devuelve error), de ahí el recover: es el mecanismo de detección de "esto
// todavía está en texto plano".
func (c *AiConfig) ClaveEnClaro() string {
if c.ApiKey == "" || app.Http.Server.Key == "" {
return c.ApiKey
}
return descifrarOTalCual(c.ApiKey)
}
func descifrarOTalCual(valor string) (out string) {
defer func() {
if recover() != nil {
out = valor
}
}()
claro := utils.Decrypt(valor, app.Http.Server.Key)
if claro == "" {
return valor
}
return claro
}
// CifrarClaveAi cifra una API key para guardarla. Si no hay APP_KEY
// configurada devuelve el valor tal cual — preferible a romper el guardado en
// un entorno sin la clave, y ClaveEnClaro lo lee igual.
func CifrarClaveAi(clave string) string {
if clave == "" || app.Http.Server.Key == "" {
return clave
}
return utils.Encrypt(clave, app.Http.Server.Key)
}
func GetAllAiConfigs(limit, offset int, search string) ([]AiConfig, int64, error) {
var items []AiConfig
var total int64
@@ -61,21 +108,6 @@ func GetAiConfigByID(id uint, out *AiConfig) error {
return app.Http.Database.DB.First(out, id).Error
}
// GetActiveAiConfig retorna la primera configuración activa del provider indicado.
// Si provider está vacío, retorna cualquier config activa.
func GetActiveAiConfig(provider string) (*AiConfig, error) {
var item AiConfig
db := app.Http.Database.DB.Where("is_active = ?", true)
if provider != "" {
db = db.Where("provider = ?", provider)
}
if err := db.First(&item).Error; err != nil {
log.Printf("[AI_CONFIG] No se encontró config activa para provider '%s': %v", provider, err)
return nil, err
}
return &item, nil
}
// SplitModulos parte el campo Modulo (comma-separated) en un slice limpio.
// "" → [] (config global), "landing,query_runner" → ["landing","query_runner"]
func SplitModulos(modulo string) []string {
@@ -107,6 +139,80 @@ func GetAiConfigSelect() ([]AiConfig, error) {
return items, nil
}
// GetAiConfigSelectPorTenants acota el selector a las configs propias de esos
// tenants más las globales del staff (tenant_id IS NULL), que son las que se
// ofrecen a todos. Sin este filtro el cliente vería las claves de los demás.
func GetAiConfigSelectPorTenants(tenantIDs []uint) ([]AiConfig, error) {
var items []AiConfig
db := app.Http.Database.DB.Model(&AiConfig{}).Select("id, nombre, provider, tenant_id")
if len(tenantIDs) == 0 {
db = db.Where("tenant_id IS NULL")
} else {
db = db.Where("tenant_id IS NULL OR tenant_id IN ?", tenantIDs)
}
if err := db.Order("nombre ASC").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// GetAiConfigsPorTenant lista las configs que cargó un cliente para su espacio.
// No incluye las globales del staff a propósito: el cliente las puede usar,
// pero no administrarlas, y mezclarlas acá invitaría a intentarlo.
func GetAiConfigsPorTenant(tenantID uint) ([]AiConfig, error) {
var items []AiConfig
err := app.Http.Database.DB.
Where("tenant_id = ?", tenantID).
Order("nombre ASC").Find(&items).Error
return items, err
}
// ContarAgentesConAiConfig dice cuántos agentes dependen de una config. Sin
// esto, borrarla los dejaría apuntando a algo inexistente y cayendo al
// proveedor global sin que nadie se entere.
func ContarAgentesConAiConfig(aiConfigID uint) (int64, error) {
var n int64
err := app.Http.Database.DB.Model(&UmindAgente{}).
Where("ai_config_id = ?", aiConfigID).Count(&n).Error
return n, err
}
// QuitarAgenteBotSalvo deja como cerebro del agente solo a la config indicada.
// GetAgenteBotAiConfig hace First() sobre es_agente_bot: con dos marcadas, cuál
// gana depende del orden de la tabla, que no es una forma de elegir nada.
func QuitarAgenteBotSalvo(id uint) {
app.Http.Database.DB.Model(&AiConfig{}).
Where("id <> ? AND es_agente_bot = ?", id, true).
Update("es_agente_bot", false)
}
// GetAgenteBotConfig retorna la config marcada como agente Telegram, con su TelegramConfig cargada.
func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
var ai AiConfig
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ? AND tenant_id IS NULL", true, true).First(&ai).Error; err != nil {
return nil, nil, fmt.Errorf("no hay agente bot configurado: %w", err)
}
if ai.TelegramConfigID == nil {
return &ai, nil, fmt.Errorf("el agente no tiene bot de Telegram asignado")
}
tg, err := GetTelegramConfigByID(*ai.TelegramConfigID)
if err != nil {
return &ai, nil, fmt.Errorf("bot de Telegram no encontrado: %w", err)
}
return &ai, tg, nil
}
// GetAgenteBotAiConfig retorna solo la config de IA marcada como agente (el mismo
// "cerebro" que usa el bot de Telegram), sin exigir que tenga un bot de Telegram
// asignado. La usa el chat propio del dashboard para compartir el mismo motor.
func GetAgenteBotAiConfig() (*AiConfig, error) {
var ai AiConfig
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ? AND tenant_id IS NULL", true, true).First(&ai).Error; err != nil {
return nil, fmt.Errorf("no hay agente configurado: %w", err)
}
return &ai, nil
}
// GetAiConfigForService retorna la config activa asignada al módulo indicado.
// Lógica de prioridad:
// 1. Config activa con modulo conteniendo service (puede ser comma-separated)
@@ -114,7 +220,15 @@ func GetAiConfigSelect() ([]AiConfig, error) {
// 3. Cualquier config activa (último recurso)
func GetAiConfigForService(service string) (*AiConfig, error) {
var items []AiConfig
if err := app.Http.Database.DB.Where("is_active = ?", true).Order("id ASC").Find(&items).Error; err != nil {
// tenant_id IS NULL: las configs de un cliente son SUYAS y solo las usa su
// agente. Sin este filtro, la del cliente que no tiene módulo asignado
// —ninguna lo tiene— caía en el fallback global y terminaba atendiendo
// tareas nuestras: clasificar correos de soporte, importar plantillas, la
// vCard. Es decir, su cuenta pagando nuestro trabajo, sin que se entere
// ninguno de los dos.
if err := app.Http.Database.DB.
Where("is_active = ? AND tenant_id IS NULL", true).
Order("id ASC").Find(&items).Error; err != nil {
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
}
if len(items) == 0 {
@@ -139,7 +253,94 @@ func GetAiConfigForService(service string) (*AiConfig, error) {
}
}
// 3. Cualquier config activa como último recurso
log.Printf("[AI_CONFIG] No se encontró config para servicio '%s', usando cualquier activa", service)
return &items[0], nil
// 3. Cualquier config activa como último recurso, pero nunca una que esté
// dedicada a un servicio que no sabe conversar: la de embeddings devuelve
// vectores y la de Whisper transcribe audio. Caer ahí daba errores del
// proveedor imposibles de relacionar con esta elección.
for i := range items {
if esConfigDeUsoEspecial(items[i].Modulo) {
continue
}
log.Printf("[AI_CONFIG] Sin config para %q, se usa %q (que no la declara)", service, items[i].Nombre)
return &items[i], nil
}
return nil, fmt.Errorf("no hay ninguna configuración de IA para %q: asignale ese módulo a una config en /app/ai-config", service)
}
// esConfigDeUsoEspecial marca los módulos cuyo endpoint no es de chat, así que
// no sirven como comodín para otra cosa.
func esConfigDeUsoEspecial(modulo string) bool {
for _, m := range SplitModulos(modulo) {
if m == "whisper" || m == "umind_embeddings" {
return true
}
}
return false
}
// HayAiConfigParaModulo dice si alguna config activa declara ese módulo.
// Sirve para elegir un módulo propio solo cuando el admin lo configuró, y si no
// caer al que se venía usando — GetAiConfigForService no lo distingue porque
// tiene fallback a la global y a cualquier activa.
func HayAiConfigParaModulo(modulo string) bool {
var items []AiConfig
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
return false
}
for i := range items {
for _, m := range SplitModulos(items[i].Modulo) {
if m == modulo {
return true
}
}
}
return false
}
// GetWhisperConfig retorna la config de IA activa etiquetada específicamente con
// el módulo "whisper" (transcripción de audio). A diferencia de
// GetAiConfigForService, NO cae a una config global: si el admin no configuró
// una explícitamente para whisper, es mejor avisar con claridad que intentar
// transcribir contra un proveedor que no soporta ese endpoint (ej. Anthropic).
func GetWhisperConfig() (*AiConfig, error) {
var items []AiConfig
// Solo configs del staff, por lo mismo que GetAiConfigForService.
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
}
for i := range items {
for _, m := range SplitModulos(items[i].Modulo) {
if m == "whisper" {
return &items[i], nil
}
}
}
return nil, fmt.Errorf("no hay ninguna configuración activa con el módulo 'whisper' en /app/ai-config")
}
// GetUmindEmbeddingsConfig retorna la config activa etiquetada con el módulo
// "umind_embeddings" (usada para generar los vectores de la base de
// conocimiento de todos los tenants de uMind). Es global, no por tenant: los
// embeddings de un tenant solo son comparables entre sí si se generaron con
// el mismo modelo, así que cambiar de config invalida los chunks existentes
// (habría que reingestar). Sin fallback, igual que GetWhisperConfig — Claude
// no ofrece embeddings, así que aquí sí importa exigir una config explícita
// en vez de caer a cualquier config activa.
func GetUmindEmbeddingsConfig() (*AiConfig, error) {
var items []AiConfig
// Global obligatoriamente: los vectores de todos los agentes tienen que
// salir del mismo modelo o la similitud coseno entre ellos no significa
// nada. Un cliente con su propio modelo de embeddings rompería su propia
// búsqueda sin ningún error visible.
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
}
for i := range items {
for _, m := range SplitModulos(items[i].Modulo) {
if m == "umind_embeddings" {
return &items[i], nil
}
}
}
return nil, fmt.Errorf("no hay ninguna configuración activa con el módulo 'umind_embeddings' en /app/ai-config (necesaria para generar embeddings, ej. un proveedor OpenAI)")
}
+62
View File
@@ -0,0 +1,62 @@
package models
import (
"testing"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/config"
"github.com/sujit-baniya/fiber-boilerplate/utils"
)
// El resto del proyecto inicializa app.Http en el arranque; en tests hay que
// hacerlo a mano antes de tocar cualquier cosa que lea la config.
func conAppKey(t *testing.T, key string) {
t.Helper()
anterior := app.Http
app.Http = &config.AppConfig{}
app.Http.Server.Key = key
t.Cleanup(func() { app.Http = anterior })
}
// La migración progresiva de AiConfig.ApiKey depende de que ClaveEnClaro
// distinga una clave cifrada de una que todavía está en texto plano. Si esto
// se rompe, el sistema empieza a mandar ciphertext como API key a OpenAI y
// todos los agentes dejan de responder.
func TestClaveEnClaroMigracionProgresiva(t *testing.T) {
conAppKey(t, "6368616e676520746869732070617373776f726420746f206120736563726574") // 32 bytes en hex
casos := []struct {
nombre string
guardado string
esperado string
descripci string
}{
{"vacío", "", "", "sin clave no hay nada que descifrar"},
{"texto plano", "sk-proj-abc123", "sk-proj-abc123", "fila vieja sin cifrar, se devuelve tal cual"},
{"cifrada", utils.Encrypt("sk-proj-abc123", app.Http.Server.Key), "sk-proj-abc123", "fila nueva, se descifra"},
{"hex que no es ciphertext", "deadbeef", "deadbeef", "hex válido pero no descifrable: no debe romper"},
}
for _, cas := range casos {
t.Run(cas.nombre, func(t *testing.T) {
cfg := &AiConfig{ApiKey: cas.guardado}
if got := cfg.ClaveEnClaro(); got != cas.esperado {
t.Errorf("%s: ClaveEnClaro() = %q, esperaba %q", cas.descripci, got, cas.esperado)
}
})
}
}
// Sin APP_KEY el guardado no debe romperse: se guarda en claro y se lee en
// claro, que es exactamente el comportamiento previo a esta migración.
func TestCifrarClaveAiSinAppKey(t *testing.T) {
conAppKey(t, "")
if got := CifrarClaveAi("sk-test"); got != "sk-test" {
t.Errorf("CifrarClaveAi sin APP_KEY = %q, esperaba pasarla tal cual", got)
}
cfg := &AiConfig{ApiKey: "sk-test"}
if got := cfg.ClaveEnClaro(); got != "sk-test" {
t.Errorf("ClaveEnClaro sin APP_KEY = %q, esperaba pasarla tal cual", got)
}
}
+68
View File
@@ -0,0 +1,68 @@
package models
import (
"os"
"strings"
"testing"
)
// El fallback de GetAiConfigForService puede terminar usando una config que no
// declara el servicio pedido. Lo que no puede es agarrar una dedicada a
// embeddings o a Whisper: esos endpoints no conversan, y el error del proveedor
// no se parece en nada a la causa real.
func TestConfigsDeUsoEspecialNoSirvenDeComodin(t *testing.T) {
casos := map[string]bool{
"whisper": true,
"umind_embeddings": true,
"landing,umind_embeddings": true,
"": false,
"ia": false,
"landing,query_runner": false,
}
for modulo, want := range casos {
if got := esConfigDeUsoEspecial(modulo); got != want {
t.Errorf("esConfigDeUsoEspecial(%q) = %v, want %v", modulo, got, want)
}
}
}
// La consulta de todo resolvedor global tiene que excluir las configs de
// cliente. Sin ese filtro, la config que carga un cliente —que no lleva módulo,
// ninguna lo lleva— cae en el fallback y pasa a atender tareas nuestras:
// clasificar correos de soporte, importar plantillas, la vCard. Su cuenta
// pagando nuestro trabajo, y sin que se entere ninguno de los dos.
//
// Se verifica sobre el código porque son consultas a base: acá no hay una.
func TestLosResolvedoresGlobalesExcluyenConfigsDeCliente(t *testing.T) {
fuente, err := os.ReadFile("ai_config.go")
if err != nil {
t.Fatal(err)
}
texto := string(fuente)
// Toda función que resuelve una config para uso del sistema.
resolvedores := []string{
"func GetAiConfigForService(",
"func GetWhisperConfig(",
"func GetUmindEmbeddingsConfig(",
"func GetAgenteBotAiConfig(",
"func GetAgenteBotConfig(",
"func HayAiConfigParaModulo(",
}
for _, firma := range resolvedores {
i := strings.Index(texto, firma)
if i < 0 {
t.Errorf("no encontré %s — ¿se renombró?", firma)
continue
}
// El cuerpo hasta la próxima función de nivel superior.
resto := texto[i+len(firma):]
if j := strings.Index(resto, "\nfunc "); j > 0 {
resto = resto[:j]
}
if !strings.Contains(resto, "tenant_id IS NULL") {
t.Errorf("%s no filtra tenant_id IS NULL: puede devolver la config de un cliente para una tarea del sistema", firma)
}
}
}
+172
View File
@@ -0,0 +1,172 @@
package models
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"net"
"strings"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// ApiKey es una credencial para /api/v2, alternativa al ADMIN_API_KEY único de
// entorno (que sigue funcionando como llave maestra para no romper lo que ya
// depende de él). A diferencia de esa llave maestra, cada ApiKey:
// - Solo funciona desde la IP/CIDR que se le asignó (obligatoria, no opcional
// — a diferencia del patrón de Pagos Externos, aquí se decidió exigirla
// siempre porque esta llave puede dar acceso a datos internos, no solo a
// pedir un cobro).
// - Solo puede usar los scopes (grupos de endpoints) que se le habilitaron.
// - Se puede revocar individualmente sin afectar a otras integraciones.
type ApiKey struct {
gorm.Model
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
TokenHash string `json:"-" gorm:"column:token_hash;uniqueIndex;size:64"`
TokenPreview string `json:"token_preview" gorm:"column:token_preview;size:12"`
IPPermitida string `json:"ip_permitida" gorm:"column:ip_permitida;size:60;not null"` // IP exacta o CIDR, obligatoria
Scopes string `json:"scopes" gorm:"column:scopes;size:300"` // comma-separated, ej: "oss,query_runner"
Activa bool `json:"activa" gorm:"column:activa;default:true"`
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
UltimoUsoAt *time.Time `json:"ultimo_uso_at" gorm:"column:ultimo_uso_at"`
UltimoUsoIP string `json:"ultimo_uso_ip" gorm:"column:ultimo_uso_ip;size:60"`
}
func (ApiKey) TableName() string { return "api_keys" }
// GenerarApiKeyToken crea un token aleatorio de 32 bytes y su hash SHA-256,
// mismo esquema que ServicioPagoExterno: el token crudo se devuelve una sola
// vez, en la base solo queda el hash.
func GenerarApiKeyToken() (raw string, hash string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", fmt.Errorf("no se pudo generar el token: %w", err)
}
raw = "sak_" + hex.EncodeToString(b)
sum := sha256.Sum256([]byte(raw))
hash = hex.EncodeToString(sum[:])
return raw, hash, nil
}
func apiKeyTokenPreview(raw string) string {
if len(raw) <= 8 {
return raw
}
return "..." + raw[len(raw)-6:]
}
func CreateApiKey(k *ApiKey) (tokenPlano string, err error) {
raw, hash, err := GenerarApiKeyToken()
if err != nil {
return "", err
}
k.TokenHash = hash
k.TokenPreview = apiKeyTokenPreview(raw)
if err := app.Http.Database.DB.Create(k).Error; err != nil {
return "", err
}
return raw, nil
}
func RegenerarApiKeyToken(id uint) (tokenPlano string, err error) {
raw, hash, err := GenerarApiKeyToken()
if err != nil {
return "", err
}
result := app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id).
Updates(map[string]interface{}{"token_hash": hash, "token_preview": apiKeyTokenPreview(raw)})
if result.Error != nil {
return "", result.Error
}
if result.RowsAffected == 0 {
return "", fmt.Errorf("api key no encontrada")
}
return raw, nil
}
func GetAllApiKeys(limit, offset int) ([]ApiKey, int64, error) {
var items []ApiKey
var total int64
db := app.Http.Database.DB.Model(&ApiKey{})
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func GetApiKeyByID(id uint) (*ApiKey, error) {
var k ApiKey
if err := app.Http.Database.DB.First(&k, id).Error; err != nil {
return nil, err
}
return &k, nil
}
// FindApiKeyActivaByToken resuelve la llave a partir del token crudo recibido
// en el header Authorization/X-API-Key. Solo hace match si está activa.
func FindApiKeyActivaByToken(rawToken string) (*ApiKey, error) {
sum := sha256.Sum256([]byte(rawToken))
hash := hex.EncodeToString(sum[:])
var k ApiKey
if err := app.Http.Database.DB.Where("token_hash = ? AND activa = ?", hash, true).First(&k).Error; err != nil {
return nil, err
}
return &k, nil
}
func UpdateApiKey(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteApiKey(id uint) error {
return app.Http.Database.DB.Delete(&ApiKey{}, id).Error
}
// RegistrarUsoApiKey deja constancia de la última vez (y desde qué IP) que se
// usó la llave, para poder detectar llaves zombis o uso desde un origen raro.
func RegistrarUsoApiKey(id uint, ip string) {
now := time.Now()
app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id).
Updates(map[string]interface{}{"ultimo_uso_at": now, "ultimo_uso_ip": ip})
}
// IPPermitida valida la IP del caller contra el CIDR/IP exacta configurada.
// A diferencia de ServicioPagoExterno, aquí es obligatoria: una ApiKey sin
// IP válida configurada nunca debe dar acceso (fail-closed).
func (k *ApiKey) IPValida(ip string) bool {
entrada := strings.TrimSpace(k.IPPermitida)
if entrada == "" {
return false
}
callerIP := net.ParseIP(ip)
if callerIP == nil {
return false
}
if strings.Contains(entrada, "/") {
_, red, err := net.ParseCIDR(entrada)
return err == nil && red.Contains(callerIP)
}
permitida := net.ParseIP(entrada)
return permitida != nil && permitida.Equal(callerIP)
}
// ScopesList devuelve los scopes habilitados para esta llave.
func (k *ApiKey) ScopesList() []string {
return SplitModulos(k.Scopes)
}
// TieneScope indica si la llave puede usar ese grupo de endpoints.
func (k *ApiKey) TieneScope(scope string) bool {
for _, s := range k.ScopesList() {
if s == scope {
return true
}
}
return false
}
+60
View File
@@ -0,0 +1,60 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// Arquitectura guarda patrones de arquitectura técnica ya resueltos (ej: Active
// Directory redundante en Azure con VPN Gateway) para que la IA los reutilice como
// referencia al generar una propuesta técnica nueva, en vez de improvisar cada vez.
type Arquitectura struct {
gorm.Model
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
ContenidoHTML string `json:"contenido_html" gorm:"column:contenido_html;type:text"` // diagrama/detalle en HTML, reutilizable como referencia
Tags string `json:"tags" gorm:"column:tags;size:255"` // comma-separated, ej: "azure,ad,vpn"
EsReferencia bool `json:"es_referencia" gorm:"column:es_referencia;default:true"` // true = patrón reutilizable, false = propuesta generada puntual
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
}
func (Arquitectura) TableName() string { return "arquitecturas" }
func GetAllArquitecturas(limit, offset int, search string, soloReferencias bool) ([]Arquitectura, int64, error) {
var items []Arquitectura
var total int64
db := app.Http.Database.DB.Model(&Arquitectura{})
if search != "" {
db = db.Where("nombre ILIKE ? OR tags ILIKE ?", "%"+search+"%", "%"+search+"%")
}
if soloReferencias {
db = db.Where("es_referencia = ?", true)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("nombre ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func GetArquitecturaByID(id uint) (*Arquitectura, error) {
var item Arquitectura
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
func CreateArquitectura(a *Arquitectura) error {
return app.Http.Database.DB.Create(a).Error
}
func UpdateArquitectura(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&Arquitectura{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteArquitectura(id uint) error {
return app.Http.Database.DB.Delete(&Arquitectura{}, id).Error
}
+256 -81
View File
@@ -3,6 +3,7 @@ package models
import (
"fmt"
"log"
"strings"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
@@ -61,19 +62,19 @@ func (Transaccion) TableName() string { return "contab_transacciones" }
type CuentaCobro struct {
gorm.Model
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;index;not null"`
Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
EntidadID *uint `json:"entidad_id" gorm:"column:entidad_id;index"`
Entidad *Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Valor float64 `json:"valor" gorm:"column:valor;not null"`
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial
FechaVencimiento *time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"`
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
Notas string `json:"notas" gorm:"column:notas;type:text"`
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;index;not null"`
Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
EntidadID *uint `json:"entidad_id" gorm:"column:entidad_id;index"`
Entidad *Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Valor float64 `json:"valor" gorm:"column:valor;not null"`
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial
FechaVencimiento *time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"`
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
Notas string `json:"notas" gorm:"column:notas;type:text"`
}
func (CuentaCobro) TableName() string { return "contab_cuentas_cobro" }
@@ -82,17 +83,22 @@ func (CuentaCobro) TableName() string { return "contab_cuentas_cobro" }
type CuentaPagar struct {
gorm.Model
EntidadID uint `json:"entidad_id" gorm:"column:entidad_id;index;not null"`
Entidad Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Valor float64 `json:"valor" gorm:"column:valor;not null"`
Vencimiento *time.Time `json:"vencimiento" gorm:"column:vencimiento"`
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
Notas string `json:"notas" gorm:"column:notas;type:text"`
EntidadID uint `json:"entidad_id" gorm:"column:entidad_id;index;not null"`
Entidad Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Valor float64 `json:"valor" gorm:"column:valor;not null"`
Vencimiento *time.Time `json:"vencimiento" gorm:"column:vencimiento"`
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
Notas string `json:"notas" gorm:"column:notas;type:text"`
// Soporte de la factura de compra (el documento del proveedor), igual que
// Factura.Archivo para las facturas de venta.
Archivo string `json:"archivo" gorm:"column:archivo"`
OriginalName string `json:"original_name" gorm:"column:original_name"`
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime"`
}
func (CuentaPagar) TableName() string { return "contab_cuentas_pagar" }
@@ -101,12 +107,12 @@ func (CuentaPagar) TableName() string { return "contab_cuentas_pagar" }
type ConsolidadoMensual struct {
gorm.Model
Anio int `json:"anio" gorm:"column:anio;not null"`
Mes int `json:"mes" gorm:"column:mes;not null"`
TotalIngresos float64 `json:"total_ingresos" gorm:"column:total_ingresos;default:0"`
TotalEgresos float64 `json:"total_egresos" gorm:"column:total_egresos;default:0"`
Anio int `json:"anio" gorm:"column:anio;not null"`
Mes int `json:"mes" gorm:"column:mes;not null"`
TotalIngresos float64 `json:"total_ingresos" gorm:"column:total_ingresos;default:0"`
TotalEgresos float64 `json:"total_egresos" gorm:"column:total_egresos;default:0"`
TotalRetenciones float64 `json:"total_retenciones" gorm:"column:total_retenciones;default:0"`
Resultado float64 `json:"resultado" gorm:"column:resultado;default:0"`
Resultado float64 `json:"resultado" gorm:"column:resultado;default:0"`
}
func (ConsolidadoMensual) TableName() string { return "contab_consolidado" }
@@ -116,7 +122,9 @@ func (ConsolidadoMensual) TableName() string { return "contab_consolidado" }
// =============================================================================
func GetAllCuentas(limit, offset int, search string) ([]Cuenta, int64, error) {
var items []Cuenta
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
items := []Cuenta{}
var total int64
db := app.Http.Database.DB.Model(&Cuenta{})
if search != "" {
@@ -132,7 +140,9 @@ func GetAllCuentas(limit, offset int, search string) ([]Cuenta, int64, error) {
}
func GetAllCuentasSelect() ([]Cuenta, error) {
var items []Cuenta
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
items := []Cuenta{}
if err := app.Http.Database.DB.Where("activo = ?", true).Order("nombre ASC").Find(&items).Error; err != nil {
return nil, err
}
@@ -162,7 +172,9 @@ func DeleteCuenta(id uint) error {
// =============================================================================
func GetAllEntidades(limit, offset int, search string) ([]Entidad, int64, error) {
var items []Entidad
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
items := []Entidad{}
var total int64
db := app.Http.Database.DB.Model(&Entidad{})
if search != "" {
@@ -179,7 +191,9 @@ func GetAllEntidades(limit, offset int, search string) ([]Entidad, int64, error)
}
func GetAllEntidadesSelect() ([]Entidad, error) {
var items []Entidad
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
items := []Entidad{}
if err := app.Http.Database.DB.Where("activo = ?", true).Order("nombre ASC").Find(&items).Error; err != nil {
return nil, err
}
@@ -190,6 +204,26 @@ func CreateEntidad(e *Entidad) error {
return app.Http.Database.DB.Create(e).Error
}
// GetOrCreateEntidadProveedor busca un proveedor por nombre (sin distinguir
// mayúsculas) y lo crea si no existe. Se usa para registrar facturas de compra
// sin obligar a dar de alta al proveedor manualmente primero.
func GetOrCreateEntidadProveedor(nombre string) (*Entidad, error) {
nombre = strings.TrimSpace(nombre)
if nombre == "" {
return nil, fmt.Errorf("nombre de proveedor requerido")
}
var e Entidad
err := app.Http.Database.DB.Where("LOWER(nombre) = LOWER(?)", nombre).First(&e).Error
if err == nil {
return &e, nil
}
e = Entidad{Nombre: nombre, Tipo: "proveedor", Activo: true}
if err := app.Http.Database.DB.Create(&e).Error; err != nil {
return nil, fmt.Errorf("no se pudo crear el proveedor: %w", err)
}
return &e, nil
}
func UpdateEntidad(e *Entidad) error {
return app.Http.Database.DB.Model(&Entidad{}).Where("id = ?", e.ID).Updates(map[string]interface{}{
"nombre": e.Nombre,
@@ -212,7 +246,9 @@ func DeleteEntidad(id uint) error {
// =============================================================================
func GetAllTransacciones(limit, offset int, search string, filtroTipo string, mes int, anio int) ([]Transaccion, int64, error) {
var items []Transaccion
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
items := []Transaccion{}
var total int64
db := app.Http.Database.DB.Model(&Transaccion{}).Preload("Cuenta").Preload("Entidad")
if search != "" {
@@ -234,11 +270,21 @@ func GetAllTransacciones(limit, offset int, search string, filtroTipo string, me
}
func CreateTransaccion(t *Transaccion) error {
return app.Http.Database.DB.Create(t).Error
if err := app.Http.Database.DB.Create(t).Error; err != nil {
return err
}
recomputarConsolidadoDeFecha(t.Fecha)
return nil
}
// UpdateTransaccion actualiza una transacción y recalcula el consolidado mensual
// del mes al que queda asociada. Si además cambió de mes, también recalcula el
// mes anterior, para que ninguno de los dos quede desincronizado.
func UpdateTransaccion(t *Transaccion) error {
return app.Http.Database.DB.Model(&Transaccion{}).Where("id = ?", t.ID).Updates(map[string]interface{}{
var anterior Transaccion
tieneAnterior := app.Http.Database.DB.First(&anterior, t.ID).Error == nil
if err := app.Http.Database.DB.Model(&Transaccion{}).Where("id = ?", t.ID).Updates(map[string]interface{}{
"fecha": t.Fecha,
"tipo": t.Tipo,
"descripcion": t.Descripcion,
@@ -248,11 +294,41 @@ func UpdateTransaccion(t *Transaccion) error {
"forma_pago": t.FormaPago,
"estado": t.Estado,
"notas": t.Notas,
}).Error
}).Error; err != nil {
return err
}
recomputarConsolidadoDeFecha(t.Fecha)
if tieneAnterior && !mismoMesYAnio(anterior.Fecha, t.Fecha) {
recomputarConsolidadoDeFecha(anterior.Fecha)
}
return nil
}
// DeleteTransaccion elimina una transacción y recalcula el consolidado mensual
// del mes al que pertenecía, para que no quede con montos que ya no existen.
func DeleteTransaccion(id uint) error {
return app.Http.Database.DB.Delete(&Transaccion{}, id).Error
var t Transaccion
tieneFecha := app.Http.Database.DB.First(&t, id).Error == nil
if err := app.Http.Database.DB.Delete(&Transaccion{}, id).Error; err != nil {
return err
}
if tieneFecha {
recomputarConsolidadoDeFecha(t.Fecha)
}
return nil
}
func mismoMesYAnio(a, b time.Time) bool {
return a.Year() == b.Year() && a.Month() == b.Month()
}
// recomputarConsolidadoDeFecha recalcula (o crea) el ConsolidadoMensual del mes
// de la fecha dada. Se ignora el error: el consolidado es derivado y se puede
// recalcular de nuevo en cualquier momento desde /contabilidad/consolidado.
func recomputarConsolidadoDeFecha(fecha time.Time) {
_, _ = CalcularYGuardarConsolidado(int(fecha.Month()), fecha.Year())
}
// =============================================================================
@@ -260,7 +336,9 @@ func DeleteTransaccion(id uint) error {
// =============================================================================
func GetAllCuentasCobro(limit, offset int, search string, estado string) ([]CuentaCobro, int64, error) {
var items []CuentaCobro
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
items := []CuentaCobro{}
var total int64
db := app.Http.Database.DB.Model(&CuentaCobro{}).Preload("Cliente").Preload("Entidad").Preload("Transaccion")
if search != "" {
@@ -307,7 +385,11 @@ func SeedBalanceData() {
}
// ─── FACTURAS → Transacciones (ingresos) ────────────────────────────────
facturas := []struct{ factura float64; valor float64; fecha string }{
facturas := []struct {
factura float64
valor float64
fecha string
}{
{54, 450000, "2026-01-01 00:00:00"},
{55, 9000000, "2026-01-01 00:00:00"},
{56, 119000, "2026-01-01 00:00:00"},
@@ -347,7 +429,7 @@ func SeedBalanceData() {
if db.Where("descripcion = ?", "Ajuste/NC ene 2026").First(&existing).Error != nil {
db.Create(&Transaccion{
Fecha: parseDate("2026-01-01 00:00:00"),
Tipo: "egreso", Descripcion: "Ajuste/NC ene 2026",
Tipo: "egreso", Descripcion: "Ajuste/NC ene 2026",
Valor: 130000, CuentaID: cuentaIng, Estado: "registrada",
})
}
@@ -369,7 +451,11 @@ func SeedBalanceData() {
}
// ─── IVA → Transacciones (egresos) ──────────────────────────────────────
ivas := []struct{ valor float64; entidad string; desc string }{
ivas := []struct {
valor float64
entidad string
desc string
}{
{285000, "DOCUXER", "IVA Fact #63"},
{503500, "DOCUXER", "IVA Fact #70"},
{19000, "GIAF SAS", "IVA Fact #56"},
@@ -383,7 +469,7 @@ func SeedBalanceData() {
eid := getEntidad(iv.entidad)
db.Create(&Transaccion{
Fecha: parseDate("2026-01-01 00:00:00"),
Tipo: "egreso", Descripcion: iv.desc,
Tipo: "egreso", Descripcion: iv.desc,
Valor: iv.valor, CuentaID: cuentaImp,
EntidadID: eid, Estado: "registrada",
})
@@ -391,7 +477,11 @@ func SeedBalanceData() {
}
// ─── RETENCION → Transacciones (egresos) ────────────────────────────────
rets := []struct{ valor float64; fecha string; estado string }{
rets := []struct {
valor float64
fecha string
estado string
}{
{195000, "2026-03-01 00:00:00", "pagado"},
{257000, "2026-02-01 00:00:00", "pagado"},
{431000, "2026-01-01 00:00:00", "pendiente"},
@@ -402,7 +492,7 @@ func SeedBalanceData() {
if db.Where("descripcion = ?", desc).First(&existing).Error != nil {
db.Create(&Transaccion{
Fecha: parseDate(r.fecha),
Tipo: "egreso", Descripcion: desc,
Tipo: "egreso", Descripcion: desc,
Valor: r.valor, CuentaID: cuentaImp,
Estado: "registrada",
})
@@ -410,7 +500,11 @@ func SeedBalanceData() {
}
// ─── CUENTAS DE COBRO (las que te pasan a ti) → CuentasPagar ────────────
cobros := []struct{ entidad string; valor float64; fecha string }{
cobros := []struct {
entidad string
valor float64
fecha string
}{
{"NATALIA", 2000000, "2026-01-01 00:00:00"},
{"FELIPE", 2000000, "2026-01-01 00:00:00"},
{"CONTADORA", 1780000, "2026-01-01 00:00:00"},
@@ -439,7 +533,10 @@ func SeedBalanceData() {
}
// ─── CONSOLIDADO POR MES ───────────────────────────────────────────────
consols := []struct{ mes int; ing, egre, resul float64 }{
consols := []struct {
mes int
ing, egre, resul float64
}{
{1, 12669000, 8467849, 4201151},
{2, 8470500, 4300000, 4170500},
{3, 2109000, 4300000, -2191000},
@@ -459,21 +556,62 @@ func SeedBalanceData() {
log.Println("[SEED] Balance data imported from BALANCE.numbers")
}
func GetCuentaCobroByID(id uint) (*CuentaCobro, error) {
var item CuentaCobro
if err := app.Http.Database.DB.Preload("Cliente").Preload("Entidad").First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
func CreateCuentaCobro(cc *CuentaCobro) error {
return app.Http.Database.DB.Create(cc).Error
}
// UpdateCuentaCobro actualiza los campos editables de una cuenta por cobrar.
// No toca transaccion_id a propósito: ese vínculo solo lo debe crear
// MarcarCuentaCobroPagada, para no perderlo en una edición cualquiera.
func UpdateCuentaCobro(cc *CuentaCobro) error {
return app.Http.Database.DB.Model(&CuentaCobro{}).Where("id = ?", cc.ID).Updates(map[string]interface{}{
"cliente_id": cc.ClienteID,
"estado": cc.Estado,
"fecha_vencimiento": cc.FechaVencimiento,
"fecha_pago": cc.FechaPago,
"transaccion_id": cc.TransaccionID,
"notas": cc.Notas,
}).Error
}
// MarcarCuentaCobroPagada registra el pago de una cuenta por cobrar: crea la
// Transaccion de tipo "ingreso" correspondiente y la vincula. Es idempotente —
// si la cuenta ya estaba pagada, no crea una transacción duplicada.
func MarcarCuentaCobroPagada(id uint, fechaPago time.Time) error {
var cc CuentaCobro
if err := app.Http.Database.DB.Preload("Cliente").First(&cc, id).Error; err != nil {
return err
}
if cc.Estado == "pagado" {
return nil
}
desc := fmt.Sprintf("Pago cuenta por cobrar #%d: %s", cc.ID, cc.Descripcion)
if cc.Cliente.Nombre != "" {
desc = fmt.Sprintf("Pago cuenta por cobrar #%d (%s): %s", cc.ID, cc.Cliente.Nombre, cc.Descripcion)
}
t := &Transaccion{
Fecha: fechaPago,
Tipo: "ingreso",
Descripcion: desc,
Valor: cc.Valor,
}
if err := CreateTransaccion(t); err != nil {
return fmt.Errorf("no se pudo crear la transacción de pago: %w", err)
}
return app.Http.Database.DB.Model(&CuentaCobro{}).Where("id = ?", id).Updates(map[string]interface{}{
"estado": "pagado",
"fecha_pago": fechaPago,
"transaccion_id": t.ID,
}).Error
}
func DeleteCuentaCobro(id uint) error {
return app.Http.Database.DB.Delete(&CuentaCobro{}, id).Error
}
@@ -482,8 +620,18 @@ func DeleteCuentaCobro(id uint) error {
// ─── CRUD: CuentaPagar ──────────────────────────────────────────────────────
// =============================================================================
func GetCuentaPagarByID(id uint) (*CuentaPagar, error) {
var item CuentaPagar
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
func GetAllCuentasPagar(limit, offset int, search string, estado string) ([]CuentaPagar, int64, error) {
var items []CuentaPagar
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
items := []CuentaPagar{}
var total int64
db := app.Http.Database.DB.Model(&CuentaPagar{}).Preload("Entidad").Preload("Transaccion")
if search != "" {
@@ -521,10 +669,35 @@ func UpdateCuentaPagar(cp *CuentaPagar) error {
}).Error
}
// MarcarCuentaPagarPagada registra el pago de una cuenta por pagar: crea la
// Transaccion de tipo "egreso" correspondiente y la vincula. Es idempotente —
// si la cuenta ya estaba pagada, no crea una transacción duplicada.
func MarcarCuentaPagarPagada(id uint, fechaPago time.Time) error {
var cp CuentaPagar
if err := app.Http.Database.DB.Preload("Entidad").First(&cp, id).Error; err != nil {
return err
}
if cp.Estado == "pagado" {
return nil
}
desc := fmt.Sprintf("Pago cuenta por pagar #%d: %s", cp.ID, cp.Descripcion)
if cp.Entidad.Nombre != "" {
desc = fmt.Sprintf("Pago cuenta por pagar #%d (%s): %s", cp.ID, cp.Entidad.Nombre, cp.Descripcion)
}
t := &Transaccion{
Fecha: fechaPago,
Tipo: "egreso",
Descripcion: desc,
Valor: cp.Valor,
EntidadID: &cp.EntidadID,
}
if err := CreateTransaccion(t); err != nil {
return fmt.Errorf("no se pudo crear la transacción de pago: %w", err)
}
return app.Http.Database.DB.Model(&CuentaPagar{}).Where("id = ?", id).Updates(map[string]interface{}{
"estado": "pagado",
"fecha_pago": fechaPago,
"estado": "pagado",
"fecha_pago": fechaPago,
"transaccion_id": t.ID,
}).Error
}
@@ -565,12 +738,12 @@ func CalcularYGuardarConsolidado(mes, anio int) (*ConsolidadoMensual, error) {
resultado := ingresos.Total - egresos.Total
c := &ConsolidadoMensual{
Anio: anio,
Mes: mes,
TotalIngresos: ingresos.Total,
TotalEgresos: egresos.Total,
Anio: anio,
Mes: mes,
TotalIngresos: ingresos.Total,
TotalEgresos: egresos.Total,
TotalRetenciones: 0,
Resultado: resultado,
Resultado: resultado,
}
var existing ConsolidadoMensual
@@ -591,7 +764,9 @@ func CalcularYGuardarConsolidado(mes, anio int) (*ConsolidadoMensual, error) {
}
func ListConsolidados(anio int) ([]ConsolidadoMensual, error) {
var items []ConsolidadoMensual
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
items := []ConsolidadoMensual{}
db := app.Http.Database.DB.Model(&ConsolidadoMensual{}).Order("anio DESC, mes DESC")
if anio > 0 {
db = db.Where("anio = ?", anio)
@@ -605,15 +780,15 @@ func ListConsolidados(anio int) ([]ConsolidadoMensual, error) {
// ─── Datos para dashboard ────────────────────────────────────────────────────
type DashboardData struct {
Mes int `json:"mes"`
Anio int `json:"anio"`
TotalIngresos float64 `json:"total_ingresos"`
TotalEgresos float64 `json:"total_egresos"`
Resultado float64 `json:"resultado"`
CantTransacciones int64 `json:"cant_transacciones"`
PendientesCobro float64 `json:"pendientes_cobro"`
PendientesPago float64 `json:"pendientes_pago"`
Transacciones []Transaccion `json:"transacciones"`
Mes int `json:"mes"`
Anio int `json:"anio"`
TotalIngresos float64 `json:"total_ingresos"`
TotalEgresos float64 `json:"total_egresos"`
Resultado float64 `json:"resultado"`
CantTransacciones int64 `json:"cant_transacciones"`
PendientesCobro float64 `json:"pendientes_cobro"`
PendientesPago float64 `json:"pendientes_pago"`
Transacciones []Transaccion `json:"transacciones"`
}
func GetDashboardData(mes, anio int) (*DashboardData, error) {
@@ -657,15 +832,15 @@ func GetDashboardData(mes, anio int) (*DashboardData, error) {
func SeedContabilidad() {
cuentas := []Cuenta{
{Codigo: "ING-FAC", Nombre: "Facturación", Tipo: "ingreso", Color: "#22c55e"},
{Codigo: "ING-OTR", Nombre: "Otros ingresos", Tipo: "ingreso", Color: "#16a34a"},
{Codigo: "EGR-HOS", Nombre: "Hosting/Servidores", Tipo: "egreso", Color: "#ef4444"},
{Codigo: "EGR-DOM", Nombre: "Dominios", Tipo: "egreso", Color: "#dc2626"},
{Codigo: "EGR-SRV", Nombre: "Servicios", Tipo: "egreso", Color: "#f97316"},
{Codigo: "ING-FAC", Nombre: "Facturación", Tipo: "ingreso", Color: "#22c55e"},
{Codigo: "ING-OTR", Nombre: "Otros ingresos", Tipo: "ingreso", Color: "#16a34a"},
{Codigo: "EGR-HOS", Nombre: "Hosting/Servidores", Tipo: "egreso", Color: "#ef4444"},
{Codigo: "EGR-DOM", Nombre: "Dominios", Tipo: "egreso", Color: "#dc2626"},
{Codigo: "EGR-SRV", Nombre: "Servicios", Tipo: "egreso", Color: "#f97316"},
{Codigo: "EGR-GRAL", Nombre: "Gastos generales", Tipo: "egreso", Color: "#eab308"},
{Codigo: "EGR-NOM", Nombre: "Nómina", Tipo: "egreso", Color: "#a855f7"},
{Codigo: "EGR-IMPU", Nombre: "Impuestos", Tipo: "egreso", Color: "#6366f1"},
{Codigo: "EGR-MKT", Nombre: "Marketing", Tipo: "egreso", Color: "#ec4899"},
{Codigo: "EGR-NOM", Nombre: "Nómina", Tipo: "egreso", Color: "#a855f7"},
{Codigo: "EGR-IMPU", Nombre: "Impuestos", Tipo: "egreso", Color: "#6366f1"},
{Codigo: "EGR-MKT", Nombre: "Marketing", Tipo: "egreso", Color: "#ec4899"},
}
for _, c := range cuentas {
var existing Cuenta
@@ -675,13 +850,13 @@ func SeedContabilidad() {
}
entidades := []Entidad{
{Nombre: "DOCUXER", Tipo: "cliente"},
{Nombre: "GIAF SAS", Tipo: "cliente"},
{Nombre: "TECZONE", Tipo: "proveedor"},
{Nombre: "FELIPE", Tipo: "proveedor"},
{Nombre: "NATALIA", Tipo: "proveedor"},
{Nombre: "CONTADORA", Tipo: "proveedor"},
{Nombre: "ANDREMER", Tipo: "proveedor"},
{Nombre: "DOCUXER", Tipo: "cliente"},
{Nombre: "GIAF SAS", Tipo: "cliente"},
{Nombre: "TECZONE", Tipo: "proveedor"},
{Nombre: "FELIPE", Tipo: "proveedor"},
{Nombre: "NATALIA", Tipo: "proveedor"},
{Nombre: "CONTADORA", Tipo: "proveedor"},
{Nombre: "ANDREMER", Tipo: "proveedor"},
}
for _, e := range entidades {
var existing Entidad
+34 -1
View File
@@ -236,14 +236,42 @@ func DeleteContrato(id uint) error {
return db.Delete(&c).Error
}
// GuardarEnlacePago persiste el enlace de pago Bold en el contrato.
// GuardarEnlacePago persiste el enlace de cobro de un ciclo en el contrato.
//
// Generar un enlace nuevo significa que empieza un ciclo de cobro nuevo, así que
// también se reinicia pago_confirmado. Sin esto el flag quedaba en true para
// siempre desde el primer pago: en la segunda renovación el cliente pagaba, pero
// GetContratosConEnlacePendiente lo excluía del polling y MarcarContratoPagado
// salía por "ya estaba pagado" sin extender la fecha de vencimiento.
func GuardarEnlacePago(contratoID uint, linkID, url string) error {
return app.Http.Database.DB.Model(&Contrato{}).Where("id = ?", contratoID).Updates(map[string]interface{}{
"enlace_pago": url,
"enlace_pago_link_id": linkID,
"pago_confirmado": false,
"fecha_pago": nil,
}).Error
}
// MarcarContratosVencidos pasa a estado "vencido" los contratos activos sin
// auto-renovación cuya fecha de vencimiento quedó atrás hace más de los días de
// gracia indicados.
//
// Sin esto ningún contrato salía nunca de "activo", y el cron de vencidos les
// seguía mandando correo con enlace de pago todos los días indefinidamente.
func MarcarContratosVencidos(diasGracia int) (int64, error) {
limite := time.Now().AddDate(0, 0, -diasGracia)
result := app.Http.Database.DB.Model(&Contrato{}).
Where("estado = ? AND fecha_vencimiento < ? AND pago_confirmado = ?", "activo", limite, false).
Update("estado", "vencido")
if result.Error != nil {
return 0, result.Error
}
if result.RowsAffected > 0 {
log.Printf("[CONTRATOS] %d contrato(s) pasaron a estado 'vencido' tras %d días sin pago", result.RowsAffected, diasGracia)
}
return result.RowsAffected, nil
}
// LimpiarEnlacePago borra el enlace vigente cuando se registra un pago aprobado,
// para que la próxima notificación genere un link nuevo.
func LimpiarEnlacePago(contratoID uint) error {
@@ -333,6 +361,11 @@ func MarcarContratoPagado(contratoID uint) (bool, error) {
result := app.Http.Database.DB.Model(&Contrato{}).
Where("id = ? AND pago_confirmado = false", contratoID).
Updates(updates)
if result.Error == nil && result.RowsAffected > 0 {
// Solo si el UPDATE realmente cambió algo: si el webhook llega dos
// veces, la segunda no entra acá y el consumo no se cierra de nuevo.
MarcarUsoFacturadoPorCliente(c.ClienteID)
}
return result.RowsAffected > 0, result.Error
}
+48 -8
View File
@@ -17,25 +17,65 @@ type CoolifyConfig struct {
func (CoolifyConfig) TableName() string { return "coolify_configs" }
// GetCoolifyConfig retorna la primera instancia activa (compatibilidad legacy).
func GetCoolifyConfig() (*CoolifyConfig, error) {
var cfg CoolifyConfig
if err := app.Http.Database.DB.First(&cfg).Error; err != nil {
if err := app.Http.Database.DB.Where("activo = ?", true).First(&cfg).Error; err != nil {
// fallback: cualquier registro
if err2 := app.Http.Database.DB.First(&cfg).Error; err2 != nil {
return nil, err2
}
}
return &cfg, nil
}
// GetCoolifyConfigByID retorna una instancia específica.
func GetCoolifyConfigByID(id uint) (*CoolifyConfig, error) {
var cfg CoolifyConfig
if err := app.Http.Database.DB.First(&cfg, id).Error; err != nil {
return nil, err
}
return &cfg, nil
}
// GetAllCoolifyConfigs retorna todas las instancias.
func GetAllCoolifyConfigs() ([]CoolifyConfig, error) {
var items []CoolifyConfig
err := app.Http.Database.DB.Order("id ASC").Find(&items).Error
return items, err
}
func CreateCoolifyConfig(cfg *CoolifyConfig) error {
return app.Http.Database.DB.Create(cfg).Error
}
func UpdateCoolifyConfig(id uint, nombre, baseURL, apiToken string, activo bool) (*CoolifyConfig, error) {
var cfg CoolifyConfig
if err := app.Http.Database.DB.First(&cfg, id).Error; err != nil {
return nil, err
}
cfg.Nombre = nombre
cfg.BaseURL = baseURL
if apiToken != "" {
cfg.ApiToken = apiToken
}
cfg.Activo = activo
if err := app.Http.Database.DB.Save(&cfg).Error; err != nil {
return nil, err
}
return &cfg, nil
}
func DeleteCoolifyConfig(id uint) error {
return app.Http.Database.DB.Delete(&CoolifyConfig{}, id).Error
}
// UpsertCoolifyConfig mantiene compatibilidad con código legado.
func UpsertCoolifyConfig(nombre, baseURL, apiToken string, activo bool) (*CoolifyConfig, error) {
var cfg CoolifyConfig
err := app.Http.Database.DB.First(&cfg).Error
if err != nil {
// No existe → crear
cfg = CoolifyConfig{
Nombre: nombre,
BaseURL: baseURL,
ApiToken: apiToken,
Activo: activo,
}
cfg = CoolifyConfig{Nombre: nombre, BaseURL: baseURL, ApiToken: apiToken, Activo: activo}
if createErr := app.Http.Database.DB.Create(&cfg).Error; createErr != nil {
return nil, createErr
}
+57
View File
@@ -0,0 +1,57 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// DocumentoGenerado es el historial de documentos producidos por el motor de
// automatización con IA (cotización, contrato, acta/arquitectura, cuenta de cobro),
// sin importar el canal que los originó (Telegram, chat propio o Claude directo).
type DocumentoGenerado struct {
gorm.Model
Tipo string `json:"tipo" gorm:"column:tipo;size:30;not null;index"` // cotizacion | contrato | acta | cuenta_cobro
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
Cliente *Cliente `json:"cliente,omitempty" gorm:"foreignKey:ClienteID"`
ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
PlantillaID *uint `json:"plantilla_id" gorm:"column:plantilla_id"`
Nombre string `json:"nombre" gorm:"column:nombre;size:200"`
Archivo string `json:"archivo" gorm:"column:archivo"` // ruta en disco, ej: uploads/documentos/cotizacion/12/xyz.pdf
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime;default:'application/pdf'"`
Tamanio int64 `json:"tamanio" gorm:"column:tamanio"`
DatosJSON string `json:"datos_json" gorm:"column:datos_json;type:text"` // input usado para generarlo (auditoría)
GeneradoPor string `json:"generado_por" gorm:"column:generado_por;size:20;default:'web'"` // web | telegram | claude_api
}
func (DocumentoGenerado) TableName() string { return "documentos_generados" }
func CreateDocumentoGenerado(d *DocumentoGenerado) error {
return app.Http.Database.DB.Create(d).Error
}
func GetAllDocumentosGenerados(limit, offset int, tipo string, clienteID uint) ([]DocumentoGenerado, int64, error) {
var items []DocumentoGenerado
var total int64
db := app.Http.Database.DB.Model(&DocumentoGenerado{}).Preload("Cliente")
if tipo != "" {
db = db.Where("tipo = ?", tipo)
}
if clienteID != 0 {
db = db.Where("cliente_id = ?", clienteID)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func GetDocumentoGeneradoByID(id uint) (*DocumentoGenerado, error) {
var item DocumentoGenerado
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
+41
View File
@@ -0,0 +1,41 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// OcrConfig almacena la conexión al servicio propio de OCR (extracción de
// texto de imágenes, ej. comprobantes de pago). Solo un registro activo a
// la vez, mismo patrón que HostingerConfig/WebSmsConfig.
type OcrConfig struct {
gorm.Model
BaseURL string `json:"base_url" gorm:"column:base_url;type:text;not null"` // ej: https://ocr.u-s.app/extract
Token string `json:"token" gorm:"column:token;type:text;not null"` // Bearer token
Notas string `json:"notas" gorm:"column:notas;type:text"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
}
func (OcrConfig) TableName() string { return "ocr_config" }
func GetOcrConfig() (*OcrConfig, error) {
var item OcrConfig
if err := app.Http.Database.DB.Where("activo = ?", true).Order("id DESC").First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
func SaveOcrConfig(s OcrConfig) error {
app.Http.Database.DB.Model(&OcrConfig{}).Where("activo = ?", true).Update("activo", false)
s.Activo = true
if s.ID > 0 {
return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
"base_url": s.BaseURL,
"token": s.Token,
"notas": s.Notas,
"activo": true,
}).Error
}
return app.Http.Database.DB.Create(&s).Error
}
+298
View File
@@ -0,0 +1,298 @@
package models
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"net"
"strings"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// ServicioPagoExterno es una credencial que le entregamos a una aplicación
// externa para que pueda pedir cobros a través de nuestras pasarelas
// (Bold/dLocal/PayPal) sin darle acceso a nada más del sistema. El token solo
// se muestra en texto plano una vez, al crearlo o regenerarlo: en la base
// solo se guarda su hash (igual que una API key de Stripe/GitHub).
type ServicioPagoExterno struct {
gorm.Model
Nombre string `json:"nombre" gorm:"column:nombre;size:150"`
TokenHash string `json:"-" gorm:"column:token_hash;uniqueIndex;size:64"`
TokenPreview string `json:"token_preview" gorm:"column:token_preview;size:12"`
IPsPermitidas string `json:"ips_permitidas" gorm:"column:ips_permitidas;type:text"`
PasarelasHabilitadas string `json:"pasarelas_habilitadas" gorm:"column:pasarelas_habilitadas;size:100"`
NotificarWebhook bool `json:"notificar_webhook" gorm:"column:notificar_webhook;default:false"`
CallbackURL string `json:"callback_url" gorm:"column:callback_url;type:text"`
CallbackSecret string `json:"-" gorm:"column:callback_secret;size:100"`
NotificarTelegram bool `json:"notificar_telegram" gorm:"column:notificar_telegram;default:false"`
TelegramChatID string `json:"telegram_chat_id" gorm:"column:telegram_chat_id;size:50"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
}
func (ServicioPagoExterno) TableName() string { return "servicios_pago_externos" }
// SolicitudPagoExterna es cada cobro individual pedido por una app externa a
// través de un ServicioPagoExterno.
type SolicitudPagoExterna struct {
gorm.Model
ServicioID uint `json:"servicio_id" gorm:"column:servicio_id;index"`
Servicio *ServicioPagoExterno `json:"servicio,omitempty" gorm:"foreignKey:ServicioID"`
ReferenciaExterna string `json:"referencia_externa" gorm:"column:referencia_externa;size:150;index"`
ReferenciaInterna string `json:"referencia_interna" gorm:"column:referencia_interna;uniqueIndex;size:60"`
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id"`
Cliente *Cliente `json:"cliente,omitempty" gorm:"foreignKey:ClienteID"`
Pasarela string `json:"pasarela" gorm:"column:pasarela;size:20"`
Monto float64 `json:"monto" gorm:"column:monto"`
Moneda string `json:"moneda" gorm:"column:moneda;size:10"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
// Estado: pendiente | pagado | fallido | expirado
Estado string `json:"estado" gorm:"column:estado;default:'pendiente';index"`
EnlacePago string `json:"enlace_pago" gorm:"column:enlace_pago;type:text"`
PasarelaLinkID string `json:"-" gorm:"column:pasarela_link_id;size:150"`
PasarelaTxID string `json:"pasarela_tx_id" gorm:"column:pasarela_tx_id;size:150"`
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
CallbackEntregado bool `json:"callback_entregado" gorm:"column:callback_entregado;default:false"`
CallbackIntentos int `json:"callback_intentos" gorm:"column:callback_intentos;default:0"`
TelegramEntregado bool `json:"telegram_entregado" gorm:"column:telegram_entregado;default:false"`
}
func (SolicitudPagoExterna) TableName() string { return "solicitudes_pago_externas" }
// ─── Tokens ───────────────────────────────────────────────────────────────────
// GenerarTokenServicioPago crea un token aleatorio de 32 bytes (64 hex chars) y
// su hash SHA-256. El token crudo se devuelve una sola vez: solo el hash se
// guarda en base de datos, así una filtración de la BD no expone credenciales
// utilizables directamente.
func GenerarTokenServicioPago() (raw string, hash string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", fmt.Errorf("no se pudo generar el token: %w", err)
}
raw = "spx_" + hex.EncodeToString(b)
hash = HashTokenServicioPago(raw)
return raw, hash, nil
}
func HashTokenServicioPago(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// tokenPreview devuelve los últimos caracteres del token para poder
// identificarlo en el panel sin volver a mostrarlo completo.
func tokenPreview(raw string) string {
if len(raw) <= 8 {
return raw
}
return "..." + raw[len(raw)-6:]
}
// ─── CRUD ServicioPagoExterno ───────────────────────────────────────────────
// CreateServicioPagoExterno genera el token, lo hashea y crea el registro.
// Devuelve el token en texto plano: es la única vez que estará disponible.
func CreateServicioPagoExterno(s *ServicioPagoExterno) (tokenPlano string, err error) {
raw, hash, err := GenerarTokenServicioPago()
if err != nil {
return "", err
}
s.TokenHash = hash
s.TokenPreview = tokenPreview(raw)
if err := app.Http.Database.DB.Create(s).Error; err != nil {
return "", err
}
return raw, nil
}
// RegenerarTokenServicioPago invalida el token anterior y genera uno nuevo.
func RegenerarTokenServicioPago(id uint) (tokenPlano string, err error) {
raw, hash, err := GenerarTokenServicioPago()
if err != nil {
return "", err
}
result := app.Http.Database.DB.Model(&ServicioPagoExterno{}).Where("id = ?", id).
Updates(map[string]interface{}{"token_hash": hash, "token_preview": tokenPreview(raw)})
if result.Error != nil {
return "", result.Error
}
if result.RowsAffected == 0 {
return "", fmt.Errorf("servicio de pago no encontrado")
}
return raw, nil
}
func GetAllServiciosPagoExterno(limit, offset int) ([]ServicioPagoExterno, int64, error) {
var items []ServicioPagoExterno
var total int64
db := app.Http.Database.DB.Model(&ServicioPagoExterno{})
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func GetServicioPagoExternoByID(id uint) (*ServicioPagoExterno, error) {
var s ServicioPagoExterno
if err := app.Http.Database.DB.First(&s, id).Error; err != nil {
return nil, err
}
return &s, nil
}
// FindServicioPagoExternoActivoByToken resuelve el servicio a partir del token
// crudo recibido en el header Authorization. Solo hace match si está activo.
func FindServicioPagoExternoActivoByToken(rawToken string) (*ServicioPagoExterno, error) {
hash := HashTokenServicioPago(rawToken)
var s ServicioPagoExterno
if err := app.Http.Database.DB.Where("token_hash = ? AND activo = ?", hash, true).First(&s).Error; err != nil {
return nil, err
}
return &s, nil
}
func UpdateServicioPagoExterno(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&ServicioPagoExterno{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteServicioPagoExterno(id uint) error {
return app.Http.Database.DB.Delete(&ServicioPagoExterno{}, id).Error
}
// IPPermitida valida la IP del caller contra la lista configurada en el
// servicio (coma-separada, admite IP exacta o CIDR). Lista vacía = sin
// restricción de IP.
func (s *ServicioPagoExterno) IPPermitida(ip string) bool {
lista := strings.TrimSpace(s.IPsPermitidas)
if lista == "" {
return true
}
callerIP := net.ParseIP(ip)
if callerIP == nil {
return false
}
for _, entrada := range strings.Split(lista, ",") {
entrada = strings.TrimSpace(entrada)
if entrada == "" {
continue
}
if strings.Contains(entrada, "/") {
_, red, err := net.ParseCIDR(entrada)
if err == nil && red.Contains(callerIP) {
return true
}
continue
}
if net.ParseIP(entrada) != nil && net.ParseIP(entrada).Equal(callerIP) {
return true
}
}
return false
}
// PasarelasList devuelve las pasarelas habilitadas para este servicio.
func (s *ServicioPagoExterno) PasarelasList() []string {
return SplitModulos(s.PasarelasHabilitadas)
}
// PasarelaHabilitada indica si el servicio puede usar esa pasarela.
func (s *ServicioPagoExterno) PasarelaHabilitada(pasarela string) bool {
for _, p := range s.PasarelasList() {
if p == pasarela {
return true
}
}
return false
}
// ─── SolicitudPagoExterna ───────────────────────────────────────────────────
// GenerarReferenciaInterna crea una referencia única con el prefijo "extpay-"
// que los webhooks de las pasarelas usan para distinguir estas solicitudes de
// las de un Contrato (que usan "contrato-{id}").
func GenerarReferenciaInterna() (string, error) {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("no se pudo generar la referencia: %w", err)
}
return "extpay-" + hex.EncodeToString(b), nil
}
func CreateSolicitudPagoExterna(s *SolicitudPagoExterna) error {
return app.Http.Database.DB.Create(s).Error
}
func GetSolicitudPagoExternaByReferenciaInterna(ref string) (*SolicitudPagoExterna, error) {
var s SolicitudPagoExterna
if err := app.Http.Database.DB.Preload("Servicio").Preload("Cliente").
Where("referencia_interna = ?", ref).First(&s).Error; err != nil {
return nil, err
}
return &s, nil
}
func GetAllSolicitudesPagoExterna(limit, offset int, servicioID uint) ([]SolicitudPagoExterna, int64, error) {
var items []SolicitudPagoExterna
var total int64
db := app.Http.Database.DB.Model(&SolicitudPagoExterna{}).Preload("Servicio").Preload("Cliente")
if servicioID > 0 {
db = db.Where("servicio_id = ?", servicioID)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
// MarcarSolicitudPagoExternaPagada transiciona pendiente→pagado de forma
// idempotente (igual patrón que MarcarContratoPagado): si ya estaba pagada,
// no vuelve a disparar la notificación. Devuelve ok=true solo en la
// transición real.
func MarcarSolicitudPagoExternaPagada(referenciaInterna, pasarelaTxID string) (ok bool, solicitud *SolicitudPagoExterna, err error) {
now := time.Now()
result := app.Http.Database.DB.Model(&SolicitudPagoExterna{}).
Where("referencia_interna = ? AND estado = ?", referenciaInterna, "pendiente").
Updates(map[string]interface{}{
"estado": "pagado",
"fecha_pago": now,
"pasarela_tx_id": pasarelaTxID,
})
if result.Error != nil {
return false, nil, result.Error
}
s, getErr := GetSolicitudPagoExternaByReferenciaInterna(referenciaInterna)
if getErr != nil {
return result.RowsAffected > 0, nil, getErr
}
return result.RowsAffected > 0, s, nil
}
// MarcarCallbackEntregado registra que la notificación saliente (webhook y/o
// Telegram) ya se intentó entregar, para no reintentar indefinidamente sin
// visibilidad.
func MarcarCallbackEntregado(id uint, webhookOk, telegramOk bool) error {
updates := map[string]interface{}{}
if webhookOk {
updates["callback_entregado"] = true
}
if telegramOk {
updates["telegram_entregado"] = true
}
if len(updates) == 0 {
return app.Http.Database.DB.Model(&SolicitudPagoExterna{}).Where("id = ?", id).
Update("callback_intentos", gorm.Expr("callback_intentos + 1")).Error
}
updates["callback_intentos"] = gorm.Expr("callback_intentos + 1")
return app.Http.Database.DB.Model(&SolicitudPagoExterna{}).Where("id = ?", id).Updates(updates).Error
}
+74
View File
@@ -0,0 +1,74 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// PlantillaDocumento es la fuente de verdad de las plantillas base usadas por
// la automatización con IA: cotización, contrato, acta de proyecto y cuenta de cobro.
// Editar una plantilla aquí actualiza automáticamente todos los canales (Telegram,
// chat propio, Claude directo) que generan ese tipo de documento.
type PlantillaDocumento struct {
gorm.Model
Tipo string `json:"tipo" gorm:"column:tipo;size:30;not null;index"` // cotizacion | contrato | acta | cuenta_cobro
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
ContenidoHTML string `json:"contenido_html" gorm:"column:contenido_html;type:text"` // Go text/template
Version int `json:"version" gorm:"column:version;default:1"`
Activa bool `json:"activa" gorm:"column:activa;default:true"`
Notas string `json:"notas" gorm:"column:notas;type:text"`
}
func (PlantillaDocumento) TableName() string { return "plantillas_documento" }
func GetAllPlantillasDocumento(limit, offset int, search, tipo string) ([]PlantillaDocumento, int64, error) {
var items []PlantillaDocumento
var total int64
db := app.Http.Database.DB.Model(&PlantillaDocumento{})
if search != "" {
db = db.Where("nombre ILIKE ?", "%"+search+"%")
}
if tipo != "" {
db = db.Where("tipo = ?", tipo)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("tipo ASC, version DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func GetPlantillaDocumentoByID(id uint) (*PlantillaDocumento, error) {
var item PlantillaDocumento
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
// GetPlantillaDocumentoActiva retorna la plantilla activa más reciente para un tipo dado.
// Es la que usan los endpoints de generación (cotizaciones, contratos, etc).
func GetPlantillaDocumentoActiva(tipo string) (*PlantillaDocumento, error) {
var item PlantillaDocumento
if err := app.Http.Database.DB.
Where("tipo = ? AND activa = ?", tipo, true).
Order("version DESC").
First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
func CreatePlantillaDocumento(p PlantillaDocumento) error {
return app.Http.Database.DB.Create(&p).Error
}
func UpdatePlantillaDocumento(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&PlantillaDocumento{}).Where("id = ?", id).Updates(updates).Error
}
func DeletePlantillaDocumento(id uint) error {
return app.Http.Database.DB.Delete(&PlantillaDocumento{}, id).Error
}
+26 -11
View File
@@ -181,12 +181,27 @@ func RemovePortalAcceso(portalUserID, clienteID uint) error {
}
// GetClienteIDsForPortalUser devuelve todos los clienteIDs accesibles para un portal user.
func GetClienteIDsForPortalUser(u *PortalUser) []uint {
isPartner := u.Rol == "partner"
if u.Role != nil {
isPartner = u.Role.EsPortalPartner
// EsPartner es la ÚNICA definición de "este usuario es partner". Existe
// porque el rol se guarda en dos lados (la columna Rol y el flag
// EsPortalPartner del Role) y consultarlos por separado se contradice: el
// dashboard decidía el layout con u.Rol y los proyectos con Role, así que un
// usuario con los dos desincronizados veía proyectos de varios clientes sin
// agrupar, o la vista agrupada vacía.
func (u *PortalUser) EsPartner() bool {
if u == nil {
return false
}
if isPartner {
if u.Role != nil {
return u.Role.EsPortalPartner
}
return u.Rol == "partner"
}
func GetClienteIDsForPortalUser(u *PortalUser) []uint {
if u == nil {
return []uint{}
}
if u.EsPartner() {
ids := make([]uint, 0, len(u.PortalAccesos))
for _, a := range u.PortalAccesos {
ids = append(ids, a.ClienteID)
@@ -202,12 +217,12 @@ func GetClienteIDsForPortalUser(u *PortalUser) []uint {
// ─── PortalPasswordResetToken ────────────────────────────────────────────────
type PortalPasswordResetToken struct {
ID uint `gorm:"primaryKey;autoIncrement"`
PortalUserID uint `gorm:"column:portal_user_id;index;not null"`
Token string `gorm:"column:token;uniqueIndex;not null"`
ExpiresAt time.Time `gorm:"column:expires_at;not null"`
Used bool `gorm:"column:used;default:false"`
CreatedAt time.Time
ID uint `gorm:"primaryKey;autoIncrement"`
PortalUserID uint `gorm:"column:portal_user_id;index;not null"`
Token string `gorm:"column:token;uniqueIndex;not null"`
ExpiresAt time.Time `gorm:"column:expires_at;not null"`
Used bool `gorm:"column:used;default:false"`
CreatedAt time.Time
}
func (PortalPasswordResetToken) TableName() string { return "portal_password_reset_tokens" }
+62
View File
@@ -0,0 +1,62 @@
package models
import "testing"
// El rol vive en dos lados (la columna Rol y el flag EsPortalPartner del
// Role). Consultarlos por separado se contradecía: el dashboard elegía el
// layout con uno y los proyectos con el otro. EsPartner es ahora la única
// definición, y estos casos fijan cuál gana.
func TestEsPartner(t *testing.T) {
casos := []struct {
nombre string
user *PortalUser
esperado bool
}{
{"nil no es partner", nil, false},
{"sin Role, rol partner", &PortalUser{Rol: "partner"}, true},
{"sin Role, rol cliente", &PortalUser{Rol: "cliente"}, false},
{"con Role, el flag manda aunque el rol diga cliente",
&PortalUser{Rol: "cliente", Role: &Roles{EsPortalPartner: true}}, true},
{"con Role, el flag manda aunque el rol diga partner",
&PortalUser{Rol: "partner", Role: &Roles{EsPortalPartner: false}}, false},
}
for _, cas := range casos {
t.Run(cas.nombre, func(t *testing.T) {
if got := cas.user.EsPartner(); got != cas.esperado {
t.Errorf("EsPartner() = %v, esperaba %v", got, cas.esperado)
}
})
}
}
// El alcance del portal se deriva de EsPartner: un desajuste acá es que un
// usuario vea proyectos de un cliente que no le corresponde.
func TestGetClienteIDsForPortalUser(t *testing.T) {
cid := uint(7)
casos := []struct {
nombre string
user *PortalUser
esperado []uint
}{
{"nil no ve nada", nil, []uint{}},
{"cliente ve el suyo", &PortalUser{Rol: "cliente", ClienteID: &cid}, []uint{7}},
{"cliente sin ClienteID no ve nada", &PortalUser{Rol: "cliente"}, []uint{}},
{"partner ve los de sus accesos",
&PortalUser{Rol: "partner", PortalAccesos: []PortalAcceso{{ClienteID: 3}, {ClienteID: 9}}},
[]uint{3, 9}},
{"partner sin accesos no ve nada", &PortalUser{Rol: "partner"}, []uint{}},
}
for _, cas := range casos {
t.Run(cas.nombre, func(t *testing.T) {
got := GetClienteIDsForPortalUser(cas.user)
if len(got) != len(cas.esperado) {
t.Fatalf("= %v, esperaba %v", got, cas.esperado)
}
for i := range got {
if got[i] != cas.esperado[i] {
t.Errorf("= %v, esperaba %v", got, cas.esperado)
}
}
})
}
}
+50
View File
@@ -327,3 +327,53 @@ func GetProyectoDocumentoByID(id uint) (*ProyectoDocumento, error) {
func DeleteProyectoDocumento(id uint) error {
return app.Http.Database.DB.Delete(&ProyectoDocumento{}, id).Error
}
// ProgresoPorProyecto calcula el avance de varios proyectos en UNA consulta
// agrupada.
//
// El dashboard hacía esto con ActualizarProgresoProyecto en un bucle: dos
// COUNT y un UPDATE por proyecto (3N consultas, N de ellas escrituras en un
// GET). Y no servía para lo que se estaba renderizando: los proyectos ya
// estaban cargados en memoria, así que el UPDATE iba a la base pero la
// pantalla seguía mostrando el valor anterior — el avance se veía siempre un
// render atrasado.
//
// Las escrituras no hacen falta: todos los caminos que tocan una fase ya
// llaman a ActualizarProgresoProyecto (ver proyecto_controller y
// proyecto_service). Acá solo se lee.
func ProgresoPorProyecto(proyectoIDs []uint) map[uint]int {
out := make(map[uint]int, len(proyectoIDs))
if len(proyectoIDs) == 0 {
return out
}
var filas []struct {
ProyectoID uint
Total int64
Completados int64
}
app.Http.Database.DB.Model(&ProyectoFase{}).
Select("proyecto_id, COUNT(*) AS total, SUM(CASE WHEN estado = 'completado' THEN 1 ELSE 0 END) AS completados").
Where("proyecto_id IN ? AND deleted_at IS NULL", proyectoIDs).
Group("proyecto_id").Scan(&filas)
for _, f := range filas {
if f.Total > 0 {
out[f.ProyectoID] = int((f.Completados * 100) / f.Total)
}
}
return out
}
// AplicarProgreso pone el avance recién calculado en los structs que se van a
// renderizar. Un proyecto sin fases queda en 0, que es lo correcto: no hay
// nada planificado todavía.
func AplicarProgreso(proyectos []Proyecto) {
ids := make([]uint, len(proyectos))
for i, p := range proyectos {
ids[i] = p.ID
}
progresos := ProgresoPorProyecto(ids)
for i := range proyectos {
proyectos[i].Progreso = progresos[proyectos[i].ID]
}
}
+95 -17
View File
@@ -9,14 +9,26 @@ import (
type ProyectoTicket struct {
gorm.Model
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
PortalUserID uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
Titulo string `json:"titulo" gorm:"column:titulo"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Estado string `json:"estado" gorm:"column:estado;default:'abierto'"` // abierto|en_progreso|resuelto|cerrado
Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"` // baja|media|alta|urgente
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
PortalUserID *uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
EmailFrom string `json:"email_from" gorm:"column:email_from;size:255"`
Titulo string `json:"titulo" gorm:"column:titulo"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Estado string `json:"estado" gorm:"column:estado;default:'abierto'"` // abierto|en_progreso|resuelto|cerrado
Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"` // baja|media|alta|urgente
Origen string `json:"origen" gorm:"column:origen;default:'portal'"` // portal|email
AsignadoA *uint `json:"asignado_a" gorm:"column:asignado_a;index"`
Asignado *Users `json:"asignado" gorm:"foreignKey:AsignadoA"`
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo que originó el ticket (dedup)
// ClienteID se resuelve al crear el ticket a partir del remitente. Si queda
// en nil el que escribió no está registrado: es un contacto externo, y eso
// también es información (no hay un campo aparte para "externo", es esto).
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
Cliente *Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
// Categoria la pone el clasificador: error | facturacion | acceso | consulta | otro
Categoria string `json:"categoria" gorm:"column:categoria;size:40;index"`
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
}
func (ProyectoTicket) TableName() string { return "proyecto_tickets" }
@@ -25,11 +37,12 @@ func (ProyectoTicket) TableName() string { return "proyecto_tickets" }
type TicketMensaje struct {
gorm.Model
TicketID uint `json:"ticket_id" gorm:"column:ticket_id;index"`
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
EsAdmin bool `json:"es_admin" gorm:"column:es_admin;default:false"`
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
LeidoPortal bool `json:"leido_portal" gorm:"column:leido_portal;default:false"`
TicketID uint `json:"ticket_id" gorm:"column:ticket_id;index"`
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
EsAdmin bool `json:"es_admin" gorm:"column:es_admin;default:false"`
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
LeidoPortal bool `json:"leido_portal" gorm:"column:leido_portal;default:false"`
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo de respuesta (dedup)
}
func (TicketMensaje) TableName() string { return "ticket_mensajes" }
@@ -39,13 +52,13 @@ func (TicketMensaje) TableName() string { return "ticket_mensajes" }
func GetTicketsByProyecto(proyectoID uint) ([]ProyectoTicket, error) {
var items []ProyectoTicket
err := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID).
Preload("Mensajes").Order("created_at DESC").Find(&items).Error
Preload("Mensajes").Preload("Asignado").Order("created_at DESC").Find(&items).Error
return items, err
}
func GetTicketByID(id uint) (*ProyectoTicket, error) {
var item ProyectoTicket
err := app.Http.Database.DB.Preload("Mensajes").First(&item, id).Error
err := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").First(&item, id).Error
return &item, err
}
@@ -62,16 +75,44 @@ func CreateTicketMensaje(m *TicketMensaje) error {
return app.Http.Database.DB.Create(m).Error
}
// EmailMessageIDYaProcesado indica si un Message-Id de correo ya generó un ticket
// o un mensaje de ticket, para no duplicar cuando el proveedor reintenta la entrega.
func EmailMessageIDYaProcesado(messageID string) bool {
if messageID == "" {
return false
}
var count int64
app.Http.Database.DB.Model(&ProyectoTicket{}).Where("message_id = ?", messageID).Count(&count)
if count > 0 {
return true
}
app.Http.Database.DB.Model(&TicketMensaje{}).Where("message_id = ?", messageID).Count(&count)
return count > 0
}
// GetUltimoTicketAbiertoPorEmail busca el ticket de origen email más reciente y no
// cerrado de un remitente, para enhebrar una respuesta en vez de abrir uno nuevo.
func GetUltimoTicketAbiertoPorEmail(email string) (*ProyectoTicket, error) {
var t ProyectoTicket
err := app.Http.Database.DB.
Where("email_from = ? AND origen = ? AND estado <> ?", email, "email", "cerrado").
Order("created_at DESC").First(&t).Error
if err != nil {
return nil, err
}
return &t, nil
}
func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) {
var items []ProyectoTicket
err := app.Http.Database.DB.Where("portal_user_id = ?", portalUserID).
Preload("Mensajes").Order("created_at DESC").Find(&items).Error
Preload("Mensajes").Preload("Asignado").Order("created_at DESC").Find(&items).Error
return items, err
}
func GetAllTickets(estado string) ([]ProyectoTicket, error) {
var items []ProyectoTicket
db := app.Http.Database.DB.Preload("Mensajes").Order("created_at DESC")
db := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").Preload("Cliente").Order("created_at DESC")
if estado != "" && estado != "todos" {
db = db.Where("estado = ?", estado)
}
@@ -79,6 +120,28 @@ func GetAllTickets(estado string) ([]ProyectoTicket, error) {
return items, err
}
func AssignedToUser(userID uint) error {
return app.Http.Database.DB.Model(&ProyectoTicket{}).
Where("asignado_a IS NULL OR asignado_a = 0").
Update("asignado_a", userID).Error
}
func CountTicketsByEstado() map[string]int64 {
result := map[string]int64{}
type row struct {
Estado string
Count int64
}
var rows []row
app.Http.Database.DB.Model(&ProyectoTicket{}).
Select("estado, count(*) as count").
Group("estado").Find(&rows)
for _, r := range rows {
result[r.Estado] = r.Count
}
return result
}
// MarkTicketMessagesReadByPortal marca como leídos todos los mensajes de admin
// de un ticket para el portal user (cuando abre el ticket).
func MarkTicketMessagesReadByPortal(ticketID uint) error {
@@ -86,3 +149,18 @@ func MarkTicketMessagesReadByPortal(ticketID uint) error {
Where("ticket_id = ? AND es_admin = true AND leido_portal = false", ticketID).
Update("leido_portal", true).Error
}
// GetClientePorEmail busca un cliente por su dirección de correo, exacta y sin
// distinguir mayúsculas. A propósito no se busca por dominio: con gmail.com o
// hotmail.com de por medio, adivinar por dominio ata tickets al cliente
// equivocado.
func GetClientePorEmail(email string) (*Cliente, error) {
var c Cliente
err := app.Http.Database.DB.
Where("LOWER(email) = LOWER(?) OR LOWER(email_cc) = LOWER(?)", email, email).
First(&c).Error
if err != nil {
return nil, err
}
return &c, nil
}
+91
View File
@@ -0,0 +1,91 @@
package models
import (
"testing"
)
func TestProyectoTicketTableName(t *testing.T) {
if got := (ProyectoTicket{}).TableName(); got != "proyecto_tickets" {
t.Errorf("TableName() = %q, want %q", got, "proyecto_tickets")
}
}
func TestTicketMensajeTableName(t *testing.T) {
if got := (TicketMensaje{}).TableName(); got != "ticket_mensajes" {
t.Errorf("TableName() = %q, want %q", got, "ticket_mensajes")
}
}
func TestProyectoTicketDefaults(t *testing.T) {
ticket := ProyectoTicket{
Titulo: "Test ticket",
Descripcion: "Descripción",
AutorNombre: "Cliente",
}
if ticket.Estado != "" {
t.Errorf("Estado debe ser vacío por defecto (GORM default), got %q", ticket.Estado)
}
if ticket.Prioridad != "" {
t.Errorf("Prioridad debe ser vacía por defecto (GORM default), got %q", ticket.Prioridad)
}
if ticket.Origen != "" {
t.Errorf("Origen debe ser vacío por defecto (GORM default), got %q", ticket.Origen)
}
if ticket.ProyectoID != nil {
t.Errorf("ProyectoID debe ser nil por defecto, got %v", ticket.ProyectoID)
}
if ticket.PortalUserID != nil {
t.Errorf("PortalUserID debe ser nil por defecto, got %v", ticket.PortalUserID)
}
}
func TestProyectoTicketEmailFrom(t *testing.T) {
ticket := ProyectoTicket{
EmailFrom: "cliente@ejemplo.com",
AutorNombre: "Cliente",
Titulo: "Soporte",
Origen: "email",
}
if ticket.EmailFrom != "cliente@ejemplo.com" {
t.Errorf("EmailFrom = %q, want %q", ticket.EmailFrom, "cliente@ejemplo.com")
}
if ticket.Origen != "email" {
t.Errorf("Origen = %q, want %q", ticket.Origen, "email")
}
}
func TestProyectoTicketAsignado(t *testing.T) {
uid := uint(42)
ticket := ProyectoTicket{
AsignadoA: &uid,
}
if ticket.AsignadoA == nil {
t.Fatal("AsignadoA debe ser no nil")
}
if *ticket.AsignadoA != 42 {
t.Errorf("AsignadoA = %d, want %d", *ticket.AsignadoA, 42)
}
}
func TestProyectoTicketAsignadoNil(t *testing.T) {
ticket := ProyectoTicket{}
if ticket.AsignadoA != nil {
t.Errorf("AsignadoA debe ser nil por defecto, got %v", *ticket.AsignadoA)
}
}
func TestTicketMensajeDefaults(t *testing.T) {
msg := TicketMensaje{
TicketID: 1,
Contenido: "Hola",
AutorNombre: "Admin",
}
if msg.EsAdmin {
t.Error("EsAdmin debe ser false por defecto")
}
if msg.LeidoPortal {
t.Error("LeidoPortal debe ser false por defecto")
}
}
// CountTicketsByEstado requiere DB real — probado en integration tests
+20 -4
View File
@@ -12,6 +12,7 @@ type QueryHistory struct {
gorm.Model
ConxDbID uint `json:"conx_db_id" gorm:"column:conx_db_id;index"`
ConxDb ConxDb `json:"conx_db" gorm:"foreignKey:ConxDbID"`
UserID uint `json:"user_id" gorm:"column:user_id;index;default:0"`
SQL string `json:"sql" gorm:"column:sql;type:text"`
Status string `json:"status" gorm:"column:status"` // ok | error
ErrorMsg string `json:"error_msg" gorm:"column:error_msg;type:text"`
@@ -29,8 +30,23 @@ func SaveQueryHistory(h QueryHistory) error {
return app.Http.Database.DB.Create(&h).Error
}
// GetQueryHistory devuelve el historial de una conexión con paginación.
func GetQueryHistory(conxDbID uint, limit, offset int) ([]QueryHistory, int64, error) {
// GetQueryHistory devuelve el historial de una conexión con paginación, filtrado por usuario.
func GetQueryHistory(conxDbID, userID uint, limit, offset int) ([]QueryHistory, int64, error) {
var items []QueryHistory
var total int64
db := app.Http.Database.DB.Model(&QueryHistory{}).Where("conx_db_id = ? AND user_id = ?", conxDbID, userID)
db.Count(&total)
err := db.Order("executed_at DESC").Limit(limit).Offset(offset).Find(&items).Error
return items, total, err
}
// DeleteQueryHistory elimina el historial de una conexión para un usuario.
func DeleteQueryHistory(conxDbID, userID uint) error {
return app.Http.Database.DB.Where("conx_db_id = ? AND user_id = ?", conxDbID, userID).Delete(&QueryHistory{}).Error
}
// GetQueryHistoryAdmin devuelve todo el historial de una conexión (solo admin).
func GetQueryHistoryAdmin(conxDbID uint, limit, offset int) ([]QueryHistory, int64, error) {
var items []QueryHistory
var total int64
db := app.Http.Database.DB.Model(&QueryHistory{}).Where("conx_db_id = ?", conxDbID)
@@ -39,7 +55,7 @@ func GetQueryHistory(conxDbID uint, limit, offset int) ([]QueryHistory, int64, e
return items, total, err
}
// DeleteQueryHistory elimina todo el historial de una conexión.
func DeleteQueryHistory(conxDbID uint) error {
// DeleteQueryHistoryAdmin elimina todo el historial de una conexión (solo admin).
func DeleteQueryHistoryAdmin(conxDbID uint) error {
return app.Http.Database.DB.Where("conx_db_id = ?", conxDbID).Delete(&QueryHistory{}).Error
}
+10 -1
View File
@@ -17,6 +17,7 @@ type Roles struct {
EsPortalCliente bool `json:"es_portal_cliente" gorm:"column:es_portal_cliente;default:false"`
EsPortalPartner bool `json:"es_portal_partner" gorm:"column:es_portal_partner;default:false"`
Submodules []Submodules `json:"submodules" gorm:"many2many:roles_submodules"`
ConxDBs []ConxDb `json:"conx_dbs" gorm:"many2many:roles_conx_db"`
}
// TableName overrides the table name used by Modules to `modules`
@@ -41,7 +42,7 @@ func AllRoles(limit, offset int, search string) ([]Roles, int64, error) {
}
// Obtener los módulos con paginación
if err := db.Order("id DESC").Limit(limit).Offset(offset).Preload("Submodules").Find(&roles).Error; err != nil {
if err := db.Order("id DESC").Limit(limit).Offset(offset).Preload("Submodules").Preload("ConxDBs").Find(&roles).Error; err != nil {
return nil, 0, err
}
@@ -62,6 +63,14 @@ func AllRolesSelect() ([]Roles, error) {
// CreateModule creates a new rol
func CreateRole(role Roles) error {
var conxDbs []ConxDb
for _, db := range role.ConxDBs {
var found ConxDb
if err := app.Http.Database.DB.First(&found, db.ID).Error; err == nil {
conxDbs = append(conxDbs, found)
}
}
role.ConxDBs = conxDbs
if err := app.Http.Database.DB.Create(&role).Error; err != nil {
return err
}
+82
View File
@@ -0,0 +1,82 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
type SoporteWebhookConfig struct {
gorm.Model
Activo bool `json:"activo" gorm:"column:activo;default:true"`
Nombre string `json:"nombre" gorm:"column:nombre;size:100"`
Provider string `json:"provider" gorm:"column:provider;size:50;default:'sendgrid'"` // sendgrid|mailgun|generic
ApiKey string `json:"api_key" gorm:"column:api_key;size:255"`
EmailDestino string `json:"email_destino" gorm:"column:email_destino;size:255"` // ej: soporte@u-s.app
ResponderAuto bool `json:"responder_auto" gorm:"column:responder_auto;default:true"`
MensajeAuto string `json:"mensaje_auto" gorm:"column:mensaje_auto;type:text"`
AsignarA *uint `json:"asignar_a" gorm:"column:asignar_a;index"` // auto-asignar tickets a este user
// SMTP salida para notificaciones y auto-respuesta
SmtpHost string `json:"smtp_host" gorm:"column:smtp_host;size:255"`
SmtpPort int `json:"smtp_port" gorm:"column:smtp_port;default:587"`
SmtpUsername string `json:"smtp_username" gorm:"column:smtp_username;size:255"`
SmtpPassword string `json:"smtp_password" gorm:"column:smtp_password;size:255"` // cifrado AES
SmtpEncryption string `json:"smtp_encryption" gorm:"column:smtp_encryption;size:20;default:'tls'"` // tls|starttls|none
SmtpFromAddr string `json:"smtp_from_addr" gorm:"column:smtp_from_addr;size:255"`
SmtpFromName string `json:"smtp_from_name" gorm:"column:smtp_from_name;size:255"`
// IMAP entrante: leer el buzón directamente en vez de depender de que un
// proveedor nos haga POST. Es lo único que hace falta para responderle a un
// cliente que escribe a soporte@ desde su correo de siempre.
ImapActivo bool `json:"imap_activo" gorm:"column:imap_activo;default:false"`
ImapHost string `json:"imap_host" gorm:"column:imap_host;size:255"`
ImapPort int `json:"imap_port" gorm:"column:imap_port;default:993"`
ImapUsername string `json:"imap_username" gorm:"column:imap_username;size:255"`
ImapPasswordEnc string `json:"-" gorm:"column:imap_password_enc;type:text"` // AES-GCM con APP_KEY
ImapEncryption string `json:"imap_encryption" gorm:"column:imap_encryption;size:20;default:'ssl'"` // ssl|starttls
ImapCarpeta string `json:"imap_carpeta" gorm:"column:imap_carpeta;size:100;default:'INBOX'"`
// Ventana hacia atrás, en horas: solo se leen los correos recibidos dentro
// de ella. 0 = sin límite (todo el buzón sin leer). Es lo que evita que la
// primera corrida se coma años de correo viejo.
ImapHorasAtras int `json:"imap_horas_atras" gorm:"column:imap_horas_atras;default:12"`
// Filtro con IA: no todo lo que llega al buzón es soporte (newsletters,
// notificaciones de bancos, facturas de proveedores). Si está prendido, se
// clasifica cada correo nuevo antes de abrir ticket.
ClasificarConIA bool `json:"clasificar_con_ia" gorm:"column:clasificar_con_ia;default:false"`
ContextoNegocio string `json:"contexto_negocio" gorm:"column:contexto_negocio;type:text"`
// Agente de uMind cuya base de conocimiento se usa para redactar borradores
// de respuesta. Sin agente el borrador se arma solo con la conversación.
AgenteBorradorID *uint `json:"agente_borrador_id" gorm:"column:agente_borrador_id;index"`
// Solo para la vista: dice si ya hay contraseña guardada sin exponerla, para
// que el formulario sepa que puede mandar el campo vacío sin borrarla.
TieneImapPassword bool `json:"tiene_imap_password" gorm:"-"`
}
func (SoporteWebhookConfig) TableName() string { return "soporte_webhook_config" }
func GetSoporteWebhookActivo() (*SoporteWebhookConfig, error) {
var item SoporteWebhookConfig
err := app.Http.Database.DB.Where("activo = ?", true).First(&item).Error
return &item, err
}
func GetAllSoporteWebhookConfigs() ([]SoporteWebhookConfig, error) {
var items []SoporteWebhookConfig
err := app.Http.Database.DB.Order("created_at DESC").Find(&items).Error
return items, err
}
func SaveSoporteWebhookConfig(s *SoporteWebhookConfig) error {
if s.ID > 0 {
// Select("*") para que los booleanos en false (activo, responder_auto,
// imap_activo) también se guarden: Updates con struct ignora los ceros,
// así que desactivar algo no tenía efecto.
return app.Http.Database.DB.Model(&SoporteWebhookConfig{}).
Where("id = ?", s.ID).
Select("*").Omit("id", "created_at", "deleted_at").
Updates(s).Error
}
return app.Http.Database.DB.Create(s).Error
}
+69
View File
@@ -0,0 +1,69 @@
package models
import (
"testing"
)
func TestSoporteWebhookConfigTableName(t *testing.T) {
if got := (SoporteWebhookConfig{}).TableName(); got != "soporte_webhook_config" {
t.Errorf("TableName() = %q, want %q", got, "soporte_webhook_config")
}
}
func TestSoporteWebhookConfigDefaults(t *testing.T) {
cfg := SoporteWebhookConfig{
Nombre: "Test",
Provider: "sendgrid",
}
if cfg.Provider != "sendgrid" {
t.Errorf("Provider = %q, want %q", cfg.Provider, "sendgrid")
}
if cfg.EmailDestino != "" {
t.Errorf("EmailDestino debe ser vacío, got %q", cfg.EmailDestino)
}
}
func TestSoporteWebhookConfigAutoResponder(t *testing.T) {
cfg := SoporteWebhookConfig{
Nombre: "Soporte",
ResponderAuto: true,
MensajeAuto: "Gracias por contactarnos",
}
if !cfg.ResponderAuto {
t.Error("ResponderAuto debe ser true")
}
if cfg.MensajeAuto != "Gracias por contactarnos" {
t.Errorf("MensajeAuto = %q, want %q", cfg.MensajeAuto, "Gracias por contactarnos")
}
}
func TestSoporteWebhookConfigAsignarA(t *testing.T) {
uid := uint(5)
cfg := SoporteWebhookConfig{
AsignarA: &uid,
}
if cfg.AsignarA == nil {
t.Fatal("AsignarA debe ser no nil")
}
if *cfg.AsignarA != 5 {
t.Errorf("AsignarA = %d, want %d", *cfg.AsignarA, 5)
}
}
func TestSoporteWebhookConfigAsignarANil(t *testing.T) {
cfg := SoporteWebhookConfig{}
if cfg.AsignarA != nil {
t.Errorf("AsignarA debe ser nil por defecto, got %v", *cfg.AsignarA)
}
}
func TestSoporteWebhookConfigSmtpDefaults(t *testing.T) {
cfg := SoporteWebhookConfig{Nombre: "SMTP Test"}
// Go zero values — los defaults reales los aplica GORM o el controller
if cfg.SmtpHost != "" {
t.Errorf("SmtpHost debe ser vacío por defecto, got %q", cfg.SmtpHost)
}
if cfg.SmtpPort != 0 {
t.Errorf("SmtpPort debe ser 0 en Go, got %d", cfg.SmtpPort)
}
}
+11
View File
@@ -82,3 +82,14 @@ func DeleteSumodule(id uint) error {
}
return nil
}
// AllSubmodulesConModulo devuelve todos los submódulos con su módulo cargado.
//
// Lo usa el menú para los administradores: como entran a todas las rutas,
// tienen que ver todos los enlaces. Con Preload porque el menú agrupa por el
// título del módulo, y sin él quedaría todo bajo un grupo vacío.
func AllSubmodulesConModulo() ([]Submodules, error) {
var items []Submodules
err := app.Http.Database.DB.Preload("Module").Order("id ASC").Find(&items).Error
return items, err
}
+24
View File
@@ -23,6 +23,30 @@ type Tarea struct {
func (Tarea) TableName() string { return "tarea" }
// RepararEstadosTareaInvalidos corrige tareas que quedaron con un estado que el
// tablero Kanban no reconoce (columnas válidas: por_hacer, en_progreso, revision,
// hecho). El agente de Telegram creó tareas con "pendiente"/"completada"/
// "cancelada" antes de esta corrección: se guardaban bien en la base de datos
// pero no aparecían en ninguna columna del listado. Es idempotente — una vez
// corregidas, no vuelve a tocarlas.
func RepararEstadosTareaInvalidos() (int64, error) {
db := app.Http.Database.DB
var total int64
mapa := map[string]string{
"pendiente": "por_hacer",
"completada": "hecho",
"cancelada": "hecho",
}
for viejo, nuevo := range mapa {
result := db.Model(&Tarea{}).Where("estado = ?", viejo).Update("estado", nuevo)
if result.Error != nil {
return total, result.Error
}
total += result.RowsAffected
}
return total, nil
}
type TareaComentario struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
TareaID uint `json:"tarea_id" gorm:"column:tarea_id;index"`
+75
View File
@@ -0,0 +1,75 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// Tarifa es la tabla de precios/reglas de negocio que la IA usa para calcular
// cotizaciones y contratos: valor por hora según tipo de servicio, licencias M365,
// tipos de VM Azure recurrentes, márgenes estándar, etc.
type Tarifa struct {
gorm.Model
Categoria string `json:"categoria" gorm:"column:categoria;size:50;not null;index"` // hora_servicio | licencia | vm_azure | margen | otro
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
Valor float64 `json:"valor" gorm:"column:valor;not null"`
Moneda string `json:"moneda" gorm:"column:moneda;size:10;default:'COP'"`
Unidad string `json:"unidad" gorm:"column:unidad;size:20"` // hora | mes | unico | porcentaje
Notas string `json:"notas" gorm:"column:notas;type:text"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
}
func (Tarifa) TableName() string { return "tarifas" }
func GetAllTarifas(limit, offset int, search, categoria string) ([]Tarifa, int64, error) {
var items []Tarifa
var total int64
db := app.Http.Database.DB.Model(&Tarifa{})
if search != "" {
db = db.Where("nombre ILIKE ?", "%"+search+"%")
}
if categoria != "" {
db = db.Where("categoria = ?", categoria)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("categoria ASC, nombre ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
// GetTarifasActivas retorna todas las tarifas activas, opcionalmente filtradas por categoría.
// Es lo que usan los endpoints de generación para calcular precios.
func GetTarifasActivas(categoria string) ([]Tarifa, error) {
var items []Tarifa
db := app.Http.Database.DB.Where("activo = ?", true)
if categoria != "" {
db = db.Where("categoria = ?", categoria)
}
if err := db.Order("nombre ASC").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func GetTarifaByID(id uint) (*Tarifa, error) {
var item Tarifa
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
func CreateTarifa(t Tarifa) error {
return app.Http.Database.DB.Create(&t).Error
}
func UpdateTarifa(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&Tarifa{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteTarifa(id uint) error {
return app.Http.Database.DB.Delete(&Tarifa{}, id).Error
}
+97
View File
@@ -0,0 +1,97 @@
package models
import (
"fmt"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// TelegramAgentHistory guarda el historial de conversación por chat_id.
// Se usan los últimos N mensajes como contexto en cada llamada al AI.
type TelegramAgentHistory struct {
gorm.Model
ChatID int64 `json:"chat_id" gorm:"column:chat_id;index;not null"`
Role string `json:"role" gorm:"column:role;not null"` // user | assistant | tool
Content string `json:"content" gorm:"column:content;type:text;not null"`
// ToolName y ToolResult se usan para mensajes de tipo "tool"
ToolName string `json:"tool_name" gorm:"column:tool_name"`
ToolResult string `json:"tool_result" gorm:"column:tool_result;type:text"`
}
func (TelegramAgentHistory) TableName() string { return "telegram_agent_history" }
// TelegramAgentChatID guarda qué chat_ids están autorizados a usar el agente.
type TelegramAgentAuth struct {
gorm.Model
ChatID int64 `json:"chat_id" gorm:"column:chat_id;uniqueIndex;not null"`
Nombre string `json:"nombre" gorm:"column:nombre"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
}
func (TelegramAgentAuth) TableName() string { return "telegram_agent_auth" }
// GetAgentHistory retorna los últimos n mensajes del historial para un chat_id.
func GetAgentHistory(chatID int64, n int) ([]TelegramAgentHistory, error) {
var items []TelegramAgentHistory
err := app.Http.Database.DB.
Where("chat_id = ?", chatID).
Order("created_at DESC").
Limit(n).
Find(&items).Error
// Invertir para orden cronológico
for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 {
items[i], items[j] = items[j], items[i]
}
return items, err
}
func SaveAgentMessage(chatID int64, role, content, toolName, toolResult string) error {
msg := &TelegramAgentHistory{
ChatID: chatID,
Role: role,
Content: content,
ToolName: toolName,
ToolResult: toolResult,
}
return app.Http.Database.DB.Create(msg).Error
}
// ClearAgentHistory borra el historial de un chat (comando /reset).
func ClearAgentHistory(chatID int64) error {
return app.Http.Database.DB.Where("chat_id = ?", chatID).Delete(&TelegramAgentHistory{}).Error
}
// IsAgentAuthChat verifica si un chat_id está autorizado.
func IsAgentAuthChat(chatID int64) bool {
var auth TelegramAgentAuth
err := app.Http.Database.DB.Where("chat_id = ? AND activo = ?", chatID, true).First(&auth).Error
return err == nil
}
func GetAllAgentAuth() ([]TelegramAgentAuth, error) {
var items []TelegramAgentAuth
err := app.Http.Database.DB.Order("created_at DESC").Find(&items).Error
return items, err
}
func CreateAgentAuth(chatID int64, nombre string) error {
auth := &TelegramAgentAuth{ChatID: chatID, Nombre: nombre, Activo: true}
return app.Http.Database.DB.Create(auth).Error
}
func DeleteAgentAuth(id uint) error {
return app.Http.Database.DB.Delete(&TelegramAgentAuth{}, id).Error
}
// GetAgentTelegramConfig retorna el TelegramConfig vinculado al agente bot activo.
func GetAgentTelegramConfig() (*TelegramConfig, error) {
var ai AiConfig
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil {
return nil, fmt.Errorf("no hay agente bot configurado")
}
if ai.TelegramConfigID == nil {
return nil, fmt.Errorf("el agente no tiene Telegram configurado")
}
return GetTelegramConfigByID(*ai.TelegramConfigID)
}
+58
View File
@@ -0,0 +1,58 @@
package models
import (
"crypto/rand"
"fmt"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// TelegramStaffToken almacena un código de verificación temporal para que un
// usuario interno (staff) vincule su Telegram y reciba notificaciones personales
// (ej: tareas asignadas), igual que TelegramPortalToken pero para Users.
type TelegramStaffToken struct {
gorm.Model
UserID uint `json:"user_id" gorm:"column:user_id;uniqueIndex"`
Token string `json:"token" gorm:"column:token;uniqueIndex;size:8"`
}
func (TelegramStaffToken) TableName() string { return "telegram_staff_tokens" }
// GenerateTelegramStaffToken genera (o renueva) el código de vinculación para el usuario.
func GenerateTelegramStaffToken(userID uint) (*TelegramStaffToken, error) {
app.Http.Database.DB.Unscoped().Where("user_id = ?", userID).Delete(&TelegramStaffToken{})
b := make([]byte, 3)
if _, err := rand.Read(b); err != nil {
return nil, fmt.Errorf("no se pudo generar token: %w", err)
}
token := fmt.Sprintf("%06X", b)
t := &TelegramStaffToken{UserID: userID, Token: token}
if err := app.Http.Database.DB.Create(t).Error; err != nil {
return nil, err
}
return t, nil
}
// GetTelegramStaffTokenByUser devuelve el token vigente de un usuario interno.
func GetTelegramStaffTokenByUser(userID uint) (*TelegramStaffToken, error) {
var t TelegramStaffToken
err := app.Http.Database.DB.Where("user_id = ?", userID).First(&t).Error
if err != nil {
return nil, err
}
return &t, nil
}
// DeleteTelegramStaffToken elimina el token de un usuario (tras vincular correctamente).
func DeleteTelegramStaffToken(userID uint) {
app.Http.Database.DB.Unscoped().Where("user_id = ?", userID).Delete(&TelegramStaffToken{})
}
// UpdateUserTelegramChatID vincula el chat_id de Telegram al usuario interno.
func UpdateUserTelegramChatID(userID uint, chatID string) error {
return app.Http.Database.DB.Model(&Users{}).Where("id = ?", userID).
Update("telegram_chat_id", chatID).Error
}
+380
View File
@@ -0,0 +1,380 @@
package models
import (
"time"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindTenant representa un sitio/cliente de uMind — dueño de los dominios
// permitidos y el nombre del negocio que se muestra al visitante. Un tenant
// puede tener varios UmindAgente independientes (cada uno con su propia
// config de IA, base de conocimiento, tools y canales); lo que antes vivía
// acá (SiteKey, AiConfigID, Tono, MensajeBienvenida, Color) se movió a
// UmindAgente — ver pkg/models/umind_agente.go y migrations.MigrarUmindAgentes.
type UmindTenant struct {
gorm.Model
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
DominiosPermitidos string `json:"dominios_permitidos" gorm:"column:dominios_permitidos;type:text"` // coma-separado, ej: u-site.app,www.u-site.app
Activo bool `json:"activo" gorm:"column:activo;default:true"`
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
// ClienteID es lo que le permite a un PortalUser llegar a sus tenants
// (ver GetClienteIDsForPortalUser). Puntero y sin not null: los tenants
// creados antes de que existiera el portal quedan sin cliente hasta que
// el staff los asigne, y el ALTER TABLE no falla sobre datos existentes.
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
PlanID *uint `json:"plan_id" gorm:"column:plan_id;index"`
// UltimoAvisoTope guarda el período ("2026-08") en que se avisó que se
// pasó el tope de consumo, para no repetirlo por cada mensaje. En la base
// y no en memoria: con despliegues varias veces al día, un flag en RAM se
// borra seguido y el cliente recibe el mismo aviso una y otra vez.
UltimoAvisoTope string `json:"ultimo_aviso_tope" gorm:"column:ultimo_aviso_tope;size:7"`
}
// MarcarAvisoTope deja registrado que ya se avisó en ese período. Devuelve
// false si el aviso ya estaba puesto, que es la señal de no volver a mandarlo.
//
// El UPDATE condicionado es lo que hace la operación atómica: si dos mensajes
// cruzan el tope a la vez, sólo uno afecta una fila y sólo ese avisa.
func MarcarAvisoTope(tenantID uint, periodo string) bool {
res := app.Http.Database.DB.Model(&UmindTenant{}).
Where("id = ? AND (ultimo_aviso_tope IS NULL OR ultimo_aviso_tope <> ?)", tenantID, periodo).
Update("ultimo_aviso_tope", periodo)
return res.Error == nil && res.RowsAffected > 0
}
func (UmindTenant) TableName() string { return "umind_tenants" }
// GenerarSiteKey crea un identificador público único para el widget de un
// agente. No se hashea (a diferencia de un token de API) porque no es un
// secreto: viaja en el HTML público del sitio del cliente.
func GenerarSiteKey() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("no se pudo generar la site_key: %w", err)
}
return "umk_" + hex.EncodeToString(b), nil
}
func CreateUmindTenant(t *UmindTenant) error {
return app.Http.Database.DB.Create(t).Error
}
func GetAllUmindTenants(limit, offset int) ([]UmindTenant, int64, error) {
var items []UmindTenant
var total int64
db := app.Http.Database.DB.Model(&UmindTenant{})
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
// GetUmindTenantsByClientes lista los tenants que pertenecen a alguno de los
// clientes dados — es la base del alcance del portal. Lista vacía devuelve
// vacío sin consultar (fail-closed: un portal user sin clientes no ve nada).
func GetUmindTenantsByClientes(clienteIDs []uint) ([]UmindTenant, error) {
if len(clienteIDs) == 0 {
return []UmindTenant{}, nil
}
var items []UmindTenant
err := app.Http.Database.DB.Where("cliente_id IN ?", clienteIDs).Order("id DESC").Find(&items).Error
return items, err
}
func GetUmindTenantByID(id uint) (*UmindTenant, error) {
var t UmindTenant
if err := app.Http.Database.DB.First(&t, id).Error; err != nil {
return nil, err
}
return &t, nil
}
func UpdateUmindTenant(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&UmindTenant{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteUmindTenant(id uint) error {
return app.Http.Database.DB.Delete(&UmindTenant{}, id).Error
}
// DominioPermitido valida el host de un Origin/Referer contra la lista
// configurada. Admite dominio exacto o comodín "*.dominio.com" para
// subdominios. Lista vacía = no permite nada (fail-closed): un tenant recién
// creado sin dominios configurados no debe poder ser usado desde ningún sitio.
func (t *UmindTenant) DominioPermitido(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return false
}
lista := strings.TrimSpace(t.DominiosPermitidos)
if lista == "" {
return false
}
for _, entrada := range strings.Split(lista, ",") {
entrada = strings.ToLower(strings.TrimSpace(entrada))
if entrada == "" {
continue
}
if strings.HasPrefix(entrada, "*.") {
sufijo := entrada[1:] // ".dominio.com"
if strings.HasSuffix(host, sufijo) {
return true
}
continue
}
if entrada == host {
return true
}
}
return false
}
// ─── Documentos y chunks de conocimiento ────────────────────────────────────
// UmindDocumento es una fuente de conocimiento de un agente: una URL
// crawleada o un archivo subido. Se trocea en UmindChunk para la búsqueda
// por similitud.
type UmindDocumento struct {
gorm.Model
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // url | archivo | texto
Origen string `json:"origen" gorm:"column:origen;type:text"` // la URL crawleada, el nombre del archivo, o el título del texto
Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente | procesando | listo | error
Error string `json:"error" gorm:"column:error;type:text"`
TotalChunks int `json:"total_chunks" gorm:"column:total_chunks;default:0"`
// Contenido guarda el texto de las fuentes que no se pueden volver a
// buscar solas (lo que escribió el dueño, lo que se extrajo de un archivo).
// Sin esto no se puede editar ni reprocesar sin volver a subir el archivo.
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
// MaxPaginas se guarda para poder recrawlear igual que la primera vez.
MaxPaginas int `json:"max_paginas" gorm:"column:max_paginas;default:0"`
// ProcesadoAt dice de cuándo es el conocimiento. Una web cambia y el agente
// sigue contestando lo viejo con total seguridad: esta fecha es lo único
// que delata que la fuente quedó vieja.
ProcesadoAt *time.Time `json:"procesado_at" gorm:"column:procesado_at"`
// AutoActualizar deja que el cron la vuelva a procesar sola.
AutoActualizar bool `json:"auto_actualizar" gorm:"column:auto_actualizar;default:false"`
}
// NombresDeTenantsUmind devuelve id → nombre para poder etiquetar cosas que
// solo guardan el id. Una consulta y no una por fila: son pocos y se usan para
// pintar una lista entera.
func NombresDeTenantsUmind() map[uint]string {
var filas []UmindTenant
out := map[uint]string{}
if err := app.Http.Database.DB.Select("id, nombre").Find(&filas).Error; err != nil {
return out
}
for _, f := range filas {
out[f.ID] = f.Nombre
}
return out
}
func (UmindDocumento) TableName() string { return "umind_documentos" }
func CreateUmindDocumento(d *UmindDocumento) error {
return app.Http.Database.DB.Create(d).Error
}
func GetUmindDocumentosByAgente(agenteID uint) ([]UmindDocumento, error) {
var items []UmindDocumento
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).Order("id DESC").Find(&items).Error
return items, err
}
func GetUmindDocumentoByID(id uint) (*UmindDocumento, error) {
var d UmindDocumento
if err := app.Http.Database.DB.First(&d, id).Error; err != nil {
return nil, err
}
return &d, nil
}
func UpdateUmindDocumentoEstado(id uint, estado, errMsg string, totalChunks int) error {
updates := map[string]interface{}{
"estado": estado,
"error": errMsg,
"total_chunks": totalChunks,
}
if estado == "listo" {
ahora := time.Now()
updates["procesado_at"] = &ahora
}
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(updates).Error
}
func UpdateUmindDocumento(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(updates).Error
}
// GetDocumentosParaRefrescar devuelve las fuentes con auto-actualización que no
// se procesan desde hace más de los días indicados.
func GetDocumentosParaRefrescar(dias int) ([]UmindDocumento, error) {
var items []UmindDocumento
corte := time.Now().AddDate(0, 0, -dias)
err := app.Http.Database.DB.
Where("auto_actualizar = ? AND estado <> ?", true, "procesando").
Where("procesado_at IS NULL OR procesado_at < ?", corte).
Find(&items).Error
return items, err
}
func DeleteUmindDocumento(id uint) error {
if err := app.Http.Database.DB.Where("documento_id = ?", id).Delete(&UmindChunk{}).Error; err != nil {
return err
}
return app.Http.Database.DB.Delete(&UmindDocumento{}, id).Error
}
// UmindChunk es un fragmento de texto con su embedding, listo para búsqueda
// por similitud. Sin pgvector por ahora: el embedding se guarda como JSON de
// []float32 y la similitud se calcula en memoria (suficiente para el volumen
// de un piloto de un solo agente; si el volumen crece, se migra a pgvector
// sin cambiar la interfaz de búsqueda).
type UmindChunk struct {
gorm.Model
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
DocumentoID uint `json:"documento_id" gorm:"column:documento_id;index;not null"`
Contenido string `json:"contenido" gorm:"column:contenido;type:text;not null"`
EmbeddingJSON string `json:"-" gorm:"column:embedding_json;type:text"`
}
func (UmindChunk) TableName() string { return "umind_chunks" }
// EmbeddingToJSON / EmbeddingFromJSON convierten el vector a/desde el formato
// de almacenamiento en texto.
func EmbeddingToJSON(v []float32) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
}
func EmbeddingFromJSON(s string) ([]float32, error) {
var v []float32
if err := json.Unmarshal([]byte(s), &v); err != nil {
return nil, err
}
return v, nil
}
// BorrarChunksDeDocumento limpia los fragmentos de una fuente antes de volver a
// procesarla. Sin esto, reprocesar deja la versión vieja y la nueva compitiendo
// en la búsqueda, y la vieja puede ganar.
func BorrarChunksDeDocumento(documentoID uint) error {
return app.Http.Database.DB.Where("documento_id = ?", documentoID).Delete(&UmindChunk{}).Error
}
func CreateUmindChunks(chunks []UmindChunk) error {
if len(chunks) == 0 {
return nil
}
return app.Http.Database.DB.CreateInBatches(chunks, 50).Error
}
// GetUmindChunksByAgente retorna todos los chunks del agente, para la
// búsqueda por similitud en memoria.
func GetUmindChunksByAgente(agenteID uint) ([]UmindChunk, error) {
var items []UmindChunk
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).Find(&items).Error
return items, err
}
// ─── Historial de conversación del widget ───────────────────────────────────
// UmindMensaje guarda el historial de conversación del widget, por agente y
// sesión de navegador (no hay usuario autenticado del lado del visitante).
type UmindMensaje struct {
gorm.Model
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
SessionID string `json:"session_id" gorm:"column:session_id;index;not null"`
Role string `json:"role" gorm:"column:role;not null"` // user | assistant
Content string `json:"content" gorm:"column:content;type:text;not null"`
}
func (UmindMensaje) TableName() string { return "umind_mensajes" }
func SaveUmindMensaje(agenteID uint, sessionID, role, content string) error {
m := &UmindMensaje{AgenteID: agenteID, SessionID: sessionID, Role: role, Content: content}
return app.Http.Database.DB.Create(m).Error
}
// GetUmindHistorial retorna los últimos n mensajes de una sesión, en orden cronológico.
func GetUmindHistorial(agenteID uint, sessionID string, n int) ([]UmindMensaje, error) {
var items []UmindMensaje
err := app.Http.Database.DB.
Where("agente_id = ? AND session_id = ?", agenteID, sessionID).
Order("created_at DESC").
Limit(n).
Find(&items).Error
for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 {
items[i], items[j] = items[j], items[i]
}
return items, err
}
// GetUmindSesiones lista las sesiones de conversación recientes de un agente
// (para el panel admin), con el último mensaje como resumen.
func GetUmindSesiones(agenteID uint, limit int) ([]UmindMensaje, error) {
var items []UmindMensaje
err := app.Http.Database.DB.Raw(`
SELECT * FROM (
SELECT DISTINCT ON (session_id) *
FROM umind_mensajes
WHERE agente_id = ? AND deleted_at IS NULL
ORDER BY session_id, created_at DESC
) ultimos
ORDER BY created_at DESC
LIMIT ?
`, agenteID, limit).Scan(&items).Error
return items, err
}
// SesionResumen es una conversación vista desde el reporte: cuándo empezó,
// cuántos mensajes tuvo y con qué la abrió el visitante. El primer mensaje es
// lo más útil del conjunto — muestra qué le preguntan de verdad al negocio.
type SesionResumen struct {
SessionID string `json:"session_id"`
Inicio time.Time `json:"inicio"`
Mensajes int64 `json:"mensajes"`
PrimerMensaje string `json:"primer_mensaje"`
}
// GetUmindSesionesRango agrupa los mensajes en conversaciones dentro de un
// rango. Una sola consulta: el primer mensaje del visitante se saca con una
// subconsulta correlacionada en vez de traer todos los mensajes y agrupar en
// Go, que en un agente con tráfico sería traerse el historial entero.
func GetUmindSesionesRango(agenteID uint, desde, hasta time.Time) ([]SesionResumen, error) {
var items []SesionResumen
err := app.Http.Database.DB.Raw(`
SELECT
m.session_id,
MIN(m.created_at) AS inicio,
COUNT(*) AS mensajes,
COALESCE((
SELECT p.content FROM umind_mensajes p
WHERE p.session_id = m.session_id AND p.agente_id = m.agente_id
AND p.role = 'user' AND p.deleted_at IS NULL
ORDER BY p.created_at ASC LIMIT 1
), '') AS primer_mensaje
FROM umind_mensajes m
WHERE m.agente_id = ? AND m.created_at >= ? AND m.created_at < ? AND m.deleted_at IS NULL
GROUP BY m.session_id, m.agente_id
ORDER BY inicio DESC
LIMIT 2000
`, agenteID, desde, hasta).Scan(&items).Error
return items, err
}
+153
View File
@@ -0,0 +1,153 @@
package models
import (
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindAgente es un agente de IA independiente dentro de un tenant — un
// mismo negocio puede tener varios (ej. "Ventas", "Soporte"), cada uno con
// su propia base de conocimiento, tools, canales y conexión de correo. Lo
// único que sigue siendo del tenant (no del agente) son los dominios
// permitidos y el nombre del negocio que se muestra al visitante — eso es
// del sitio, no de un agente puntual.
type UmindAgente struct {
gorm.Model
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"` // etiqueta interna, ej: "Ventas"
SiteKey string `json:"site_key" gorm:"column:site_key;uniqueIndex;size:40;not null"`
AiConfigID *uint `json:"ai_config_id" gorm:"column:ai_config_id"`
Tono string `json:"tono" gorm:"column:tono;type:text"`
MensajeBienvenida string `json:"mensaje_bienvenida" gorm:"column:mensaje_bienvenida;type:text"`
Color string `json:"color" gorm:"column:color;size:7;default:'#8eb02f'"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
}
func (UmindAgente) TableName() string { return "umind_agentes" }
func CreateUmindAgente(a *UmindAgente) error {
if a.SiteKey == "" {
key, err := GenerarSiteKey()
if err != nil {
return err
}
a.SiteKey = key
}
if a.Color == "" {
a.Color = "#8eb02f"
}
return app.Http.Database.DB.Create(a).Error
}
func GetUmindAgentesByTenant(tenantID uint) ([]UmindAgente, error) {
var items []UmindAgente
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).Order("id ASC").Find(&items).Error
return items, err
}
// GetTodosLosUmindAgentes lista todos los agentes de todos los tenants. Es solo
// para pantallas de staff que necesitan elegir uno (ej. de qué base de
// conocimiento salen los borradores de soporte).
func GetTodosLosUmindAgentes() ([]UmindAgente, error) {
var items []UmindAgente
err := app.Http.Database.DB.Order("nombre ASC").Find(&items).Error
return items, err
}
func GetUmindAgenteByID(id uint) (*UmindAgente, error) {
var a UmindAgente
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
return nil, err
}
return &a, nil
}
// GetUmindAgenteBySiteKey resuelve el agente a partir de la site_key pública
// que manda el widget. Solo hace match si el agente está activo.
func GetUmindAgenteBySiteKey(siteKey string) (*UmindAgente, error) {
var a UmindAgente
if err := app.Http.Database.DB.Where("site_key = ? AND activo = ?", siteKey, true).First(&a).Error; err != nil {
return nil, err
}
return &a, nil
}
func UpdateUmindAgente(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&UmindAgente{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteUmindAgente(id uint) error {
return app.Http.Database.DB.Delete(&UmindAgente{}, id).Error
}
// GetUmindAgentePorSiteKey resuelve el agente por site_key SIN filtrar por
// activo, para que el llamador pueda distinguir "no existe" de "está
// apagado" y decirlo. GetUmindAgenteBySiteKey (que sí filtra) queda para
// quien solo necesita el camino feliz.
func GetUmindAgentePorSiteKey(siteKey string) (*UmindAgente, error) {
var a UmindAgente
if err := app.Http.Database.DB.Where("site_key = ?", siteKey).First(&a).Error; err != nil {
return nil, err
}
return &a, nil
}
// ResumenAgente son las señales que hacen falta para saber de un vistazo si
// un agente está listo o le falta algo, sin entrar a configurarlo.
type ResumenAgente struct {
AgenteID uint `json:"agente_id"`
Documentos int64 `json:"documentos"`
Canales int64 `json:"canales"`
Conversaciones7d int64 `json:"conversaciones_7d"`
}
// GetResumenAgentes agrega las tres señales en tres consultas agrupadas, no
// en tres por agente: con 5 agentes la diferencia no se nota, pero el patrón
// N+1 en una pantalla de listado es el que después no se puede sacar.
func GetResumenAgentes(agenteIDs []uint) map[uint]*ResumenAgente {
out := make(map[uint]*ResumenAgente, len(agenteIDs))
for _, id := range agenteIDs {
out[id] = &ResumenAgente{AgenteID: id}
}
if len(agenteIDs) == 0 {
return out
}
db := app.Http.Database.DB
type fila struct {
AgenteID uint
Total int64
}
var docs []fila
db.Model(&UmindDocumento{}).Select("agente_id, COUNT(*) AS total").
Where("agente_id IN ? AND deleted_at IS NULL", agenteIDs).Group("agente_id").Scan(&docs)
for _, f := range docs {
if r, ok := out[f.AgenteID]; ok {
r.Documentos = f.Total
}
}
var canales []fila
db.Model(&UmindCanal{}).Select("agente_id, COUNT(*) AS total").
Where("agente_id IN ? AND activo = ? AND deleted_at IS NULL", agenteIDs, true).Group("agente_id").Scan(&canales)
for _, f := range canales {
if r, ok := out[f.AgenteID]; ok {
r.Canales = f.Total
}
}
// Conversaciones, no mensajes: lo que importa es a cuánta gente atendió.
var convs []fila
db.Model(&UmindMensaje{}).Select("agente_id, COUNT(DISTINCT session_id) AS total").
Where("agente_id IN ? AND created_at >= ? AND deleted_at IS NULL", agenteIDs, time.Now().AddDate(0, 0, -7)).
Group("agente_id").Scan(&convs)
for _, f := range convs {
if r, ok := out[f.AgenteID]; ok {
r.Conversaciones7d = f.Total
}
}
return out
}
+93
View File
@@ -0,0 +1,93 @@
package models
import (
"crypto/rand"
"encoding/hex"
"fmt"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindCanal es un canal de mensajería adicional (Telegram, WhatsApp) que
// alimenta al mismo agente del tenant que ya atiende el widget web. Los
// secretos reales (bot token, access token de WhatsApp, etc.) viven cifrados
// en CredencialesEnc (ver pkg/services/umind_secrets.go) — el modelo solo
// persiste el string ya cifrado, no conoce la clave.
//
// WebhookSecret es un identificador público generado por nosotros, distinto
// del secreto real del proveedor, usado SOLO para enrutar el webhook
// entrante al canal correcto (va en la URL que se registra en
// Telegram/Meta). Evita que el token real del proveedor termine en logs de
// acceso o de un proxy intermedio.
type UmindCanal struct {
gorm.Model
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
Tipo string `json:"tipo" gorm:"column:tipo;size:20;not null"` // telegram | whatsapp
Activo bool `json:"activo" gorm:"column:activo;default:true"`
WebhookSecret string `json:"webhook_secret" gorm:"column:webhook_secret;uniqueIndex;size:40;not null"`
CredencialesEnc string `json:"-" gorm:"column:credenciales_enc;type:text"`
UltimoError string `json:"ultimo_error" gorm:"column:ultimo_error;type:text"`
// Si están activos, los mensajes de voz/audio e imágenes que llegan por
// este canal se transcriben (Whisper) o se les extrae el texto (OCR)
// antes de pasarlos al agente, en vez de ignorarse.
UsarWhisperAudio bool `json:"usar_whisper_audio" gorm:"column:usar_whisper_audio;default:false"`
UsarOcrImagenes bool `json:"usar_ocr_imagenes" gorm:"column:usar_ocr_imagenes;default:false"`
// Documentos adjuntos (PDF, Word, texto): se les extrae el contenido y se
// le pasa al agente como si el cliente lo hubiera escrito.
UsarArchivosDocs bool `json:"usar_archivos_docs" gorm:"column:usar_archivos_docs;default:false"`
}
func (UmindCanal) TableName() string { return "umind_canales" }
func GenerarWebhookSecret() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("no se pudo generar el webhook_secret: %w", err)
}
return "umc_" + hex.EncodeToString(b), nil
}
func CreateUmindCanal(c *UmindCanal) error {
if c.WebhookSecret == "" {
secret, err := GenerarWebhookSecret()
if err != nil {
return err
}
c.WebhookSecret = secret
}
return app.Http.Database.DB.Create(c).Error
}
func GetUmindCanalesByAgente(agenteID uint) ([]UmindCanal, error) {
var items []UmindCanal
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).Order("id DESC").Find(&items).Error
return items, err
}
func GetUmindCanalByID(id uint) (*UmindCanal, error) {
var c UmindCanal
if err := app.Http.Database.DB.First(&c, id).Error; err != nil {
return nil, err
}
return &c, nil
}
// GetUmindCanalByWebhookSecret resuelve el canal a partir del identificador
// público que viene en la URL del webhook. Solo matchea si está activo.
func GetUmindCanalByWebhookSecret(tipo, webhookSecret string) (*UmindCanal, error) {
var c UmindCanal
err := app.Http.Database.DB.Where("tipo = ? AND webhook_secret = ? AND activo = ?", tipo, webhookSecret, true).First(&c).Error
if err != nil {
return nil, err
}
return &c, nil
}
func UpdateUmindCanal(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&UmindCanal{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteUmindCanal(id uint) error {
return app.Http.Database.DB.Delete(&UmindCanal{}, id).Error
}
+75
View File
@@ -0,0 +1,75 @@
package models
import (
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindConexion es una cuenta de correo real (Gmail u Outlook) conectada por
// OAuth a un agente, para que pueda enviar y leer correo en su nombre (tools
// enviar_correo/leer_bandeja, ver pkg/services/umind_agent_service.go).
// AccessTokenEnc/RefreshTokenEnc viajan cifrados en reposo (ver
// pkg/services/umind_secrets.go) — a diferencia del site_key del widget,
// estos SÍ son secretos: quien los tenga puede leer/mandar correo como el
// dueño de la cuenta.
type UmindConexion struct {
gorm.Model
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
Proveedor string `json:"proveedor" gorm:"column:proveedor;size:20;not null"` // google | microsoft
Email string `json:"email" gorm:"column:email;size:255"`
AccessTokenEnc string `json:"-" gorm:"column:access_token_enc;type:text"`
RefreshTokenEnc string `json:"-" gorm:"column:refresh_token_enc;type:text"`
ExpiraEn time.Time `json:"expira_en" gorm:"column:expira_en"`
Scopes string `json:"scopes" gorm:"column:scopes;type:text"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
}
func (UmindConexion) TableName() string { return "umind_conexiones" }
func CreateUmindConexion(c *UmindConexion) error {
return app.Http.Database.DB.Create(c).Error
}
func GetUmindConexionesByAgente(agenteID uint) ([]UmindConexion, error) {
var items []UmindConexion
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).Order("id DESC").Find(&items).Error
return items, err
}
func GetUmindConexionByID(id uint) (*UmindConexion, error) {
var c UmindConexion
if err := app.Http.Database.DB.First(&c, id).Error; err != nil {
return nil, err
}
return &c, nil
}
// GetUmindConexionActiva retorna la primera conexión activa del agente —
// hoy se soporta una sola cuenta de correo conectada por agente, no una
// bandeja por proveedor a la vez.
func GetUmindConexionActiva(agenteID uint) (*UmindConexion, error) {
var c UmindConexion
err := app.Http.Database.DB.Where("agente_id = ? AND activo = ?", agenteID, true).First(&c).Error
if err != nil {
return nil, err
}
return &c, nil
}
// DesactivarConexionesDelAgente se llama antes de crear una conexión nueva —
// hoy se soporta una sola cuenta de correo activa por agente a la vez.
func DesactivarConexionesDelAgente(agenteID uint) error {
return app.Http.Database.DB.Model(&UmindConexion{}).
Where("agente_id = ? AND activo = ?", agenteID, true).
Update("activo", false).Error
}
func UpdateUmindConexion(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&UmindConexion{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteUmindConexion(id uint) error {
return app.Http.Database.DB.Delete(&UmindConexion{}, id).Error
}
+51
View File
@@ -0,0 +1,51 @@
package models
import (
"log"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindEventoLog es el registro de auditoría técnica de un agente — errores
// y eventos que hoy solo quedaban en el log del servidor (invisibles desde
// el panel): fallos al llamar al AI, tools/webhooks que fallan, canales que
// no logran procesar un mensaje, etc. No es el historial de conversación
// (eso es UmindMensaje) ni el estado de ingesta (eso ya se ve por
// documento en UmindDocumento.Estado/Error) — es específicamente lo que
// antes solo se podía ver pidiendo los logs del servidor.
type UmindEventoLog struct {
gorm.Model
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
Nivel string `json:"nivel" gorm:"column:nivel;size:10"` // error | warn
Origen string `json:"origen" gorm:"column:origen;size:40"` // ai | tool | email | canal_telegram | canal_whatsapp
Mensaje string `json:"mensaje" gorm:"column:mensaje;type:text"`
Detalle string `json:"detalle" gorm:"column:detalle;type:text"` // JSON crudo opcional, para diagnosticar sin pedir logs
}
func (UmindEventoLog) TableName() string { return "umind_eventos_log" }
// RegistrarEventoUmind guarda un evento de auditoría. No devuelve error a
// propósito — es un side-channel de diagnóstico, nunca debe interrumpir el
// flujo principal (responderle al visitante) si la escritura falla.
func RegistrarEventoUmind(agenteID uint, nivel, origen, mensaje, detalle string) {
e := &UmindEventoLog{AgenteID: agenteID, Nivel: nivel, Origen: origen, Mensaje: mensaje, Detalle: detalle}
if err := app.Http.Database.DB.Create(e).Error; err != nil {
log.Printf("[UMIND] No se pudo guardar el evento de auditoría (agente %d): %v", agenteID, err)
}
}
// GetUmindEventosByAgente retorna los últimos eventos del agente, más
// reciente primero — acotado para no crecer sin límite en la respuesta.
func GetUmindEventosByAgente(agenteID uint, limit int) ([]UmindEventoLog, error) {
if limit <= 0 || limit > 200 {
limit = 100
}
var items []UmindEventoLog
err := app.Http.Database.DB.
Where("agente_id = ?", agenteID).
Order("id DESC").
Limit(limit).
Find(&items).Error
return items, err
}
+66
View File
@@ -0,0 +1,66 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindPlan define qué puede hacer un tenant y a qué precio. Los precios se
// copian a cada UmindUso al momento de registrarlo, así que subir un precio
// nunca revalúa consumo ya facturado ni pendiente.
type UmindPlan struct {
gorm.Model
Nombre string `json:"nombre" gorm:"column:nombre;size:100;not null"`
MaxAgentes int `json:"max_agentes" gorm:"column:max_agentes;default:1"` // 0 = ilimitado
PrecioMensual float64 `json:"precio_mensual" gorm:"column:precio_mensual;default:0"`
PrecioPor1kTokens float64 `json:"precio_por_1k_tokens" gorm:"column:precio_por_1k_tokens;default:0"`
PrecioPorOCR float64 `json:"precio_por_ocr" gorm:"column:precio_por_ocr;default:0"`
PrecioPorTranscripcion float64 `json:"precio_por_transcripcion" gorm:"column:precio_por_transcripcion;default:0"`
Moneda string `json:"moneda" gorm:"column:moneda;size:3;default:'COP'"`
// 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"`
}
func (UmindPlan) TableName() string { return "umind_planes" }
func CreateUmindPlan(p *UmindPlan) error {
return app.Http.Database.DB.Create(p).Error
}
func GetUmindPlanes() ([]UmindPlan, error) {
var items []UmindPlan
err := app.Http.Database.DB.Order("id ASC").Find(&items).Error
return items, err
}
func GetUmindPlanByID(id uint) (*UmindPlan, error) {
var p UmindPlan
if err := app.Http.Database.DB.First(&p, id).Error; err != nil {
return nil, err
}
return &p, nil
}
func UpdateUmindPlan(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&UmindPlan{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteUmindPlan(id uint) error {
return app.Http.Database.DB.Delete(&UmindPlan{}, id).Error
}
// GetPlanDeTenant devuelve el plan del tenant, o nil si no tiene uno asignado
// (tenants viejos). Los llamadores tratan nil como "sin límites ni precios".
func GetPlanDeTenant(tenantID uint) *UmindPlan {
t, err := GetUmindTenantByID(tenantID)
if err != nil || t.PlanID == nil {
return nil
}
p, err := GetUmindPlanByID(*t.PlanID)
if err != nil {
return nil
}
return p
}
+118
View File
@@ -0,0 +1,118 @@
package models
import (
"encoding/json"
"fmt"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindHerramientaMax es el máximo de tools activas por tenant — acota el
// tamaño del prompt (cada tool declarada se manda entera al modelo en cada
// mensaje) y la superficie de webhooks que un tenant puede disparar.
const UmindHerramientaMax = 10
// UmindHerramientaParametro describe un parámetro que el modelo debe
// completar al invocar la tool. Es un JSON Schema simplificado (solo tipos
// primitivos) para que el staff lo pueda armar desde un formulario sin
// escribir JSON a mano.
type UmindHerramientaParametro struct {
Nombre string `json:"nombre"`
Tipo string `json:"tipo"` // string | number | boolean
Descripcion string `json:"descripcion"`
Requerido bool `json:"requerido"`
}
// UmindHerramienta es una tool custom de un tenant: cuando el agente decide
// usarla, se hace un POST a URL con los argumentos que decidió el modelo. El
// valor de AuthHeaderValorEnc viaja cifrado en reposo (ver
// pkg/services/umind_secrets.go) porque es un secreto de terceros que hay
// que poder recuperar tal cual para reenviarlo, a diferencia de una
// contraseña propia que solo necesitamos poder verificar.
type UmindHerramienta struct {
gorm.Model
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
Nombre string `json:"nombre" gorm:"column:nombre;size:64;not null"` // identificador de function-calling, ej: "consultar_stock"
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text;not null"`
ParametrosJSON string `json:"parametros_json" gorm:"column:parametros_json;type:text"` // []UmindHerramientaParametro
URL string `json:"url" gorm:"column:url;type:text;not null"`
AuthHeaderNombre string `json:"auth_header_nombre" gorm:"column:auth_header_nombre;size:100"` // ej: "Authorization", opcional
AuthHeaderValorEnc string `json:"-" gorm:"column:auth_header_valor_enc;type:text"`
Activa bool `json:"activa" gorm:"column:activa;default:true"`
}
func (UmindHerramienta) TableName() string { return "umind_herramientas" }
func ParametrosToJSON(p []UmindHerramientaParametro) (string, error) {
b, err := json.Marshal(p)
if err != nil {
return "", err
}
return string(b), nil
}
func ParametrosFromJSON(s string) ([]UmindHerramientaParametro, error) {
if s == "" {
return nil, nil
}
var p []UmindHerramientaParametro
if err := json.Unmarshal([]byte(s), &p); err != nil {
return nil, err
}
return p, nil
}
func CreateUmindHerramienta(h *UmindHerramienta) error {
var activas int64
if err := app.Http.Database.DB.Model(&UmindHerramienta{}).
Where("agente_id = ? AND activa = ?", h.AgenteID, true).Count(&activas).Error; err != nil {
return err
}
if activas >= UmindHerramientaMax {
return fmt.Errorf("este agente ya tiene el máximo de %d tools activas", UmindHerramientaMax)
}
return app.Http.Database.DB.Create(h).Error
}
func GetUmindHerramientasByAgente(agenteID uint) ([]UmindHerramienta, error) {
var items []UmindHerramienta
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).Order("id DESC").Find(&items).Error
return items, err
}
// GetUmindHerramientasActivas retorna las tools activas del agente, para
// armar el toolset del agente en cada mensaje.
func GetUmindHerramientasActivas(agenteID uint) ([]UmindHerramienta, error) {
var items []UmindHerramienta
err := app.Http.Database.DB.Where("agente_id = ? AND activa = ?", agenteID, true).Find(&items).Error
return items, err
}
func GetUmindHerramientaByID(id uint) (*UmindHerramienta, error) {
var h UmindHerramienta
if err := app.Http.Database.DB.First(&h, id).Error; err != nil {
return nil, err
}
return &h, nil
}
// GetUmindHerramientaByNombre resuelve una tool por nombre dentro del
// agente — así arma la llamada real cuando el modelo pide ejecutar
// "consultar_stock", por ejemplo.
func GetUmindHerramientaByNombre(agenteID uint, nombre string) (*UmindHerramienta, error) {
var h UmindHerramienta
err := app.Http.Database.DB.Where("agente_id = ? AND nombre = ? AND activa = ?", agenteID, nombre, true).First(&h).Error
if err != nil {
return nil, err
}
return &h, nil
}
func UpdateUmindHerramienta(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&UmindHerramienta{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteUmindHerramienta(id uint) error {
return app.Http.Database.DB.Delete(&UmindHerramienta{}, id).Error
}
+234
View File
@@ -0,0 +1,234 @@
package models
import (
"log"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// Tipos de consumo medible.
const (
UsoTipoIA = "ia"
UsoTipoOCR = "ocr"
UsoTipoWhisper = "whisper"
)
// UmindUso es una línea de consumo facturable. El Costo se congela con el
// precio vigente del plan al momento de registrarlo: subir un precio nunca
// revalúa consumo ya ocurrido, que es lo que haría imposible defender una
// factura ante un reclamo.
//
// TenantID está desnormalizado a propósito (se puede derivar del agente) para
// poder sumar el consumo de un ciclo sin joins.
type UmindUso struct {
gorm.Model
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index"`
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
Tipo string `json:"tipo" gorm:"column:tipo;size:10;index"`
Cantidad float64 `json:"cantidad" gorm:"column:cantidad"`
Unidad string `json:"unidad" gorm:"column:unidad;size:20"`
Costo float64 `json:"costo" gorm:"column:costo"`
Moneda string `json:"moneda" gorm:"column:moneda;size:3"`
// FacturadoAt null = pendiente de cobrar en el próximo ciclo.
FacturadoAt *time.Time `json:"facturado_at" gorm:"column:facturado_at;index"`
// CuentaPropia marca el consumo que salió por la cuenta de IA del propio
// cliente. Se sigue midiendo —quiere ver cuánto usa su asistente— pero con
// costo cero: ya se lo factura su proveedor, y cobrárselo también sería
// cobrar dos veces por lo mismo.
CuentaPropia bool `json:"cuenta_propia" gorm:"column:cuenta_propia;default:false;index"`
}
func (UmindUso) TableName() string { return "umind_uso" }
// RegistrarUsoUmind nunca devuelve error, igual que RegistrarEventoUmind: es
// contabilidad lateral y jamás debe tumbar la respuesta al visitante. Si
// falla, queda en el log para reconciliar a mano.
func RegistrarUsoUmind(agenteID uint, tipo string, cantidad float64, unidad string) {
if agenteID == 0 || cantidad <= 0 {
return
}
agente, err := GetUmindAgenteByID(agenteID)
if err != nil {
log.Printf("[UMIND_USO] agente %d no encontrado, no se registra el consumo: %v", agenteID, err)
return
}
plan := GetPlanDeTenant(agente.TenantID)
moneda := "COP"
if plan != nil {
moneda = plan.Moneda
}
// Si el agente corre sobre la cuenta de IA del propio cliente, el consumo
// se registra igual pero no se le cobra: su proveedor ya se lo factura.
// Solo aplica a los tokens de IA — el OCR y la transcripción son nuestros
// servicios, los use quien los use.
propia := tipo == UsoTipoIA && agenteUsaCuentaPropia(agente)
costo := 0.0
if !propia {
costo = costoDeUso(plan, tipo, cantidad)
}
uso := &UmindUso{
TenantID: agente.TenantID, AgenteID: agenteID,
Tipo: tipo, Cantidad: cantidad, Unidad: unidad,
Costo: costo, Moneda: moneda, CuentaPropia: propia,
}
if err := app.Http.Database.DB.Create(uso).Error; err != nil {
log.Printf("[UMIND_USO] no se pudo registrar consumo del agente %d (%s %.2f %s): %v", agenteID, tipo, cantidad, unidad, err)
}
}
// costoDeUso aplica la tarifa del plan. Sin plan (tenants viejos) o tipo no
// tarifado, el consumo se registra pero no cuesta.
func costoDeUso(plan *UmindPlan, tipo string, cantidad float64) float64 {
if plan == nil {
return 0
}
switch tipo {
case UsoTipoIA:
return cantidad / 1000 * plan.PrecioPor1kTokens
case UsoTipoOCR:
return cantidad * plan.PrecioPorOCR
case UsoTipoWhisper:
return cantidad * plan.PrecioPorTranscripcion
}
return 0
}
// GetUsoUmind lista el consumo de un tenant en un rango. tipo vacío = todos.
func GetUsoUmind(tenantID uint, desde, hasta time.Time, tipo string) ([]UmindUso, error) {
var items []UmindUso
db := app.Http.Database.DB.Where("tenant_id = ? AND created_at >= ? AND created_at < ?", tenantID, desde, hasta)
if tipo != "" {
db = db.Where("tipo = ?", tipo)
}
err := db.Order("created_at DESC").Limit(1000).Find(&items).Error
return items, err
}
// ResumenUso es el agregado por tipo que se muestra en el panel y se adjunta
// al correo de cobro.
type ResumenUso struct {
Tipo string `json:"tipo"`
Unidad string `json:"unidad"`
Cantidad float64 `json:"cantidad"`
Costo float64 `json:"costo"`
Eventos int64 `json:"eventos"`
}
func GetResumenUso(tenantID uint, desde, hasta time.Time) ([]ResumenUso, error) {
var out []ResumenUso
err := app.Http.Database.DB.Model(&UmindUso{}).
Select("tipo, MAX(unidad) AS unidad, SUM(cantidad) AS cantidad, SUM(costo) AS costo, COUNT(*) AS eventos").
Where("tenant_id = ? AND created_at >= ? AND created_at < ? AND deleted_at IS NULL", tenantID, desde, hasta).
Group("tipo").Scan(&out).Error
return out, err
}
// SumarUsoPendiente devuelve el consumo todavía no facturado de un tenant —
// es lo que se le suma a la mensualidad al generar el link de cobro.
func SumarUsoPendiente(tenantID uint) (float64, error) {
var total float64
err := app.Http.Database.DB.Model(&UmindUso{}).
Where("tenant_id = ? AND facturado_at IS NULL AND deleted_at IS NULL", tenantID).
Select("COALESCE(SUM(costo), 0)").Scan(&total).Error
return total, err
}
// MarcarUsoFacturado cierra el consumo pendiente de un tenant. Es idempotente
// por construcción: el filtro facturado_at IS NULL hace que una segunda
// llamada (webhook de pago duplicado) no encuentre nada que marcar.
func MarcarUsoFacturado(tenantID uint) error {
ahora := time.Now()
return app.Http.Database.DB.Model(&UmindUso{}).
Where("tenant_id = ? AND facturado_at IS NULL", tenantID).
Update("facturado_at", ahora).Error
}
// ─── Puente con la facturación por contrato ─────────────────────────────────
// El cobro recurrente ya existente vive en Contrato (ver renovacion_service).
// Un contrato es de un Cliente, y un Cliente puede tener varios tenants de
// uMind, así que el consumo se agrega por cliente, no por tenant.
func tenantIDsDeCliente(clienteID uint) []uint {
if clienteID == 0 {
return nil
}
tenants, err := GetUmindTenantsByClientes([]uint{clienteID})
if err != nil || len(tenants) == 0 {
return nil
}
ids := make([]uint, 0, len(tenants))
for _, t := range tenants {
ids = append(ids, t.ID)
}
return ids
}
// ConsumoPendientePorCliente suma lo consumido y todavía no facturado de
// todos los tenants de un cliente. Es lo que se le agrega a la mensualidad
// al generar el link de cobro del ciclo.
func ConsumoPendientePorCliente(clienteID uint) float64 {
ids := tenantIDsDeCliente(clienteID)
if len(ids) == 0 {
return 0
}
var total float64
if err := app.Http.Database.DB.Model(&UmindUso{}).
Where("tenant_id IN ? AND facturado_at IS NULL AND deleted_at IS NULL", ids).
Select("COALESCE(SUM(costo), 0)").Scan(&total).Error; err != nil {
log.Printf("[UMIND_USO] no se pudo sumar el consumo pendiente del cliente %d: %v", clienteID, err)
return 0
}
return total
}
// ResumenPendientePorCliente es el desglose que se adjunta al correo de cobro.
// Un cobro variable sin detalle es una disputa asegurada.
func ResumenPendientePorCliente(clienteID uint) []ResumenUso {
ids := tenantIDsDeCliente(clienteID)
if len(ids) == 0 {
return nil
}
var out []ResumenUso
if err := app.Http.Database.DB.Model(&UmindUso{}).
Select("tipo, MAX(unidad) AS unidad, SUM(cantidad) AS cantidad, SUM(costo) AS costo, COUNT(*) AS eventos").
Where("tenant_id IN ? AND facturado_at IS NULL AND deleted_at IS NULL", ids).
Group("tipo").Scan(&out).Error; err != nil {
return nil
}
return out
}
// MarcarUsoFacturadoPorCliente cierra el consumo pendiente tras confirmarse
// el pago. Idempotente por el filtro facturado_at IS NULL: un webhook de pago
// repetido no encuentra nada que marcar y no vuelve a cobrar.
func MarcarUsoFacturadoPorCliente(clienteID uint) {
ids := tenantIDsDeCliente(clienteID)
if len(ids) == 0 {
return
}
ahora := time.Now()
if err := app.Http.Database.DB.Model(&UmindUso{}).
Where("tenant_id IN ? AND facturado_at IS NULL", ids).
Update("facturado_at", ahora).Error; err != nil {
log.Printf("[UMIND_USO] no se pudo marcar como facturado el consumo del cliente %d: %v", clienteID, err)
}
}
// agenteUsaCuentaPropia dice si el agente apunta a una config de IA cargada por
// el cliente (con tenant), en vez de a una nuestra.
func agenteUsaCuentaPropia(agente *UmindAgente) bool {
if agente == nil || agente.AiConfigID == nil {
return false
}
var cfg AiConfig
if err := GetAiConfigByID(*agente.AiConfigID, &cfg); err != nil {
return false
}
return cfg.TenantID != nil
}
+43
View File
@@ -0,0 +1,43 @@
package models
import "testing"
// El costo se congela con el precio del plan al momento de registrar el
// consumo. Si esta cuenta se rompe, se le cobra de más o de menos a un
// cliente real, así que va con test.
func TestCalculoCostoPorTipo(t *testing.T) {
plan := &UmindPlan{
PrecioPor1kTokens: 2.5,
PrecioPorOCR: 10,
PrecioPorTranscripcion: 40,
}
casos := []struct {
tipo string
cantidad float64
esperado float64
}{
{UsoTipoIA, 1000, 2.5}, // exactamente 1k tokens
{UsoTipoIA, 500, 1.25}, // fracción de 1k, no se redondea hacia arriba
{UsoTipoIA, 3200, 8.0}, // varios miles
{UsoTipoOCR, 1, 10}, // una imagen
{UsoTipoOCR, 3, 30}, // varias
{UsoTipoWhisper, 1, 40}, // una transcripción
{"desconocido", 100, 0}, // tipo no tarifado no cobra nada
}
for _, cas := range casos {
got := costoDeUso(plan, cas.tipo, cas.cantidad)
if got != cas.esperado {
t.Errorf("costoDeUso(%s, %.0f) = %.4f, esperaba %.4f", cas.tipo, cas.cantidad, got, cas.esperado)
}
}
}
// Sin plan asignado (tenants viejos) el consumo se registra pero no cuesta —
// no debe explotar ni inventar un precio.
func TestCalculoCostoSinPlan(t *testing.T) {
if got := costoDeUso(nil, UsoTipoIA, 5000); got != 0 {
t.Errorf("costoDeUso sin plan = %.4f, esperaba 0", got)
}
}
+21 -7
View File
@@ -45,9 +45,12 @@ func AllUsersGas(limit, offset int, search string) ([]Users, int64, error) {
var total int64
db := app.Http.Database.DB.Model(&Users{})
// Filtrar por término de búsqueda si se proporciona
// ILIKE y no LIKE: en Postgres LIKE distingue mayúsculas, así que buscar
// "lizandro" no encontraba a "Lizandro". Se agrega el email porque es lo
// que la mayoría escribe cuando busca a una persona.
if search != "" {
db = db.Where("name LIKE ? OR nombre_usuario LIKE ?", "%"+search+"%", "%"+search+"%")
patron := "%" + search + "%"
db = db.Where("name ILIKE ? OR nombre_usuario ILIKE ? OR email ILIKE ?", patron, patron, patron)
}
// Filtrar por tipo_usuario = 'gas'
db = db.Where("tipo_usuario = ?", "gas")
@@ -72,13 +75,24 @@ func AllUsersSistema(limit, offset int, search string) ([]Users, int64, error) {
var total int64
db := app.Http.Database.DB.Model(&Users{})
// Filtrar por término de búsqueda si se proporciona
// ILIKE y no LIKE: en Postgres LIKE distingue mayúsculas, así que buscar
// "lizandro" no encontraba a "Lizandro". Se agrega el email porque es lo
// que la mayoría escribe cuando busca a una persona.
if search != "" {
db = db.Where("name LIKE ? OR nombre_usuario LIKE ?", "%"+search+"%", "%"+search+"%")
patron := "%" + search + "%"
db = db.Where("name ILIKE ? OR nombre_usuario ILIKE ? OR email ILIKE ?", patron, patron, patron)
}
// Filtrar por tipo_usuario = 'sistema' o vacío (excluye usuarios 'gas' y otros tipos)
db = db.Where("tipo_usuario = ? OR tipo_usuario = '' OR tipo_usuario IS NULL", "sistema")
// Se excluye 'gas' en vez de exigir 'sistema'.
//
// Antes era una lista blanca ('sistema', '' o NULL) y la pantalla de 'gas'
// es la complementaria, así que un usuario con cualquier otro valor —o con
// uno viejo de antes de que existiera el campo— no aparecía en NINGUNA de
// las dos listas: quedaba invisible en todo el panel aunque pudiera entrar
// y fuera administrador.
//
// Invertirlo garantiza que todo usuario esté en exactamente una de las dos.
db = db.Where("COALESCE(tipo_usuario, '') <> ?", "gas")
// Obtener el total de usuarios
if err := db.Count(&total).Error; err != nil {
@@ -175,7 +189,7 @@ func FindUserByID(id interface{}) (*Users, error) {
db := app.Http.Database.DB.Model(&Users{})
// Buscar el usuario por ID
if err := db.Where("id = ?", id).Preload("Role.Submodules.Module").First(&user).Error; err != nil {
if err := db.Where("id = ?", id).Preload("Role.Submodules.Module").Preload("Role.ConxDBs").First(&user).Error; err != nil {
// Manejar el error si el registro no se encuentra
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("usuario no encontrado con ID: %d", id)
+44
View File
@@ -0,0 +1,44 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// WhisperAsrConfig almacena la conexión al servicio propio de transcripción
// de audio (whisper-asr-webservice self-hosted, autenticado con Basic Auth)
// — distinto del Whisper de OpenAI que ya se configura vía AiConfig con
// modulo "whisper" para el bot de Telegram. Solo un registro activo a la vez.
type WhisperAsrConfig struct {
gorm.Model
BaseURL string `json:"base_url" gorm:"column:base_url;type:text;not null"` // ej: https://whisper.u-s.app/asr
Username string `json:"username" gorm:"column:username;size:255;not null"`
Password string `json:"password" gorm:"column:password;type:text;not null"`
Notas string `json:"notas" gorm:"column:notas;type:text"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
}
func (WhisperAsrConfig) TableName() string { return "whisper_asr_config" }
func GetWhisperAsrConfig() (*WhisperAsrConfig, error) {
var item WhisperAsrConfig
if err := app.Http.Database.DB.Where("activo = ?", true).Order("id DESC").First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
func SaveWhisperAsrConfig(s WhisperAsrConfig) error {
app.Http.Database.DB.Model(&WhisperAsrConfig{}).Where("activo = ?", true).Update("activo", false)
s.Activo = true
if s.ID > 0 {
return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
"base_url": s.BaseURL,
"username": s.Username,
"password": s.Password,
"notas": s.Notas,
"activo": true,
}).Error
}
return app.Http.Database.DB.Create(&s).Error
}
+42
View File
@@ -0,0 +1,42 @@
package services
import (
"encoding/json"
"strings"
"testing"
)
// TestAgentMessageRawRoundTrip confirma que un campo que el proveedor manda
// y que nuestra struct no conoce (ej. thought_signature de Gemini) sobrevive
// a un ciclo unmarshal->marshal en vez de perderse — es justo lo que hacía
// que Gemini rechazara la segunda ronda de una tool call.
func TestAgentMessageRawRoundTrip(t *testing.T) {
original := `{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"buscar_conocimiento","arguments":"{}"},"thought_signature":"opaco-123"}]}`
var msg agentMessage
if err := json.Unmarshal([]byte(original), &msg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(msg.ToolCalls) != 1 || msg.ToolCalls[0].Function.Name != "buscar_conocimiento" {
t.Fatalf("no se poblaron los campos tipados normalmente: %+v", msg)
}
out, err := json.Marshal(msg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(out), `"thought_signature":"opaco-123"`) {
t.Errorf("se perdió el campo desconocido al reserializar: %s", out)
}
// Un mensaje armado por nosotros (no parseado) no debe llevar Raw ni
// romperse por eso — tiene que serializar normal.
propio := agentMessage{Role: "user", Content: "hola"}
outPropio, err := json.Marshal(propio)
if err != nil {
t.Fatalf("marshal de mensaje propio: %v", err)
}
if !strings.Contains(string(outPropio), `"role":"user"`) || !strings.Contains(string(outPropio), `"content":"hola"`) {
t.Errorf("mensaje propio no serializó bien: %s", outPropio)
}
}
+121
View File
@@ -0,0 +1,121 @@
package services
import (
"fmt"
"sort"
"strings"
"sync"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// Resolución de entidades por nombre para el agente de Telegram.
//
// La mayoría de las herramientas pedían un ID numérico, y el modelo tenía que
// conseguirlo antes: llamar a listar_*, leer la respuesta, encontrar la fila
// correcta y extraer el número. Cuatro pasos encadenados por cada acción, y
// basta que falle uno para que no se guarde nada — es lo que pasó con una
// factura cuyo cliente no salía en la primera página del listado.
//
// Aceptando el nombre y resolviéndolo acá, esa cadena desaparece: el modelo
// pasa lo que el usuario dijo y el servidor hace la búsqueda, que además es
// exacta y no depende de que el listado estuviera paginado.
// ResolverClienteID devuelve el ID a partir de un id explícito o de un nombre.
//
// Si el nombre coincide con varios clientes devuelve un error que los enumera:
// es mejor que el modelo pregunte cuál a que elija uno al azar y la factura
// termine cargada a otra empresa.
func ResolverClienteID(id uint, nombre string) (uint, error) {
if id > 0 {
return id, nil
}
nombre = strings.TrimSpace(nombre)
if nombre == "" {
return 0, fmt.Errorf("indicá el cliente: su nombre o su id")
}
items, _, err := models.GetAllClientes(50, 0, nombre)
if err != nil {
return 0, err
}
switch len(items) {
case 0:
return 0, fmt.Errorf("no encontré ningún cliente que coincida con %q; revisá el nombre o creálo con crear_cliente", nombre)
case 1:
return items[0].ID, nil
}
// Una coincidencia exacta gana sobre las parciales: "Metropolitana" no
// debería quedar ambiguo solo porque existe "Metropolitana Norte".
buscado := strings.ToLower(nombre)
for _, c := range items {
if strings.ToLower(strings.TrimSpace(c.Nombre)) == buscado ||
strings.ToLower(strings.TrimSpace(c.Empresa)) == buscado {
return c.ID, nil
}
}
var opciones []string
for i, c := range items {
if i == 8 {
opciones = append(opciones, fmt.Sprintf("y %d más", len(items)-8))
break
}
etiqueta := c.Nombre
if c.Empresa != "" && !strings.EqualFold(c.Empresa, c.Nombre) {
etiqueta = fmt.Sprintf("%s (%s)", c.Nombre, c.Empresa)
}
opciones = append(opciones, fmt.Sprintf("%d = %s", c.ID, etiqueta))
}
return 0, fmt.Errorf("hay varios clientes que coinciden con %q, preguntale al usuario cuál: %s",
nombre, strings.Join(opciones, "; "))
}
// ─── Salud de las herramientas ───────────────────────────────────────────────
// Contadores en memoria de uso y fallo por herramienta.
//
// Hasta ahora cada problema se diagnosticaba de a un caso: el usuario avisaba
// que algo no se guardó y había que reconstruir qué pasó. Con esto se ve de
// una si el fallo es de una herramienta puntual o del modelo eligiendo mal.
//
// ponytail: en memoria, se reinicia con el proceso. Alcanza para responder
// "¿qué está fallando esta semana?"; si hiciera falta histórico, va a tabla.
var (
agentStatsMu sync.Mutex
agentUsos = map[string]int{}
agentFallos = map[string]int{}
)
func RegistrarUsoHerramienta(nombre string, fallo bool) {
agentStatsMu.Lock()
defer agentStatsMu.Unlock()
agentUsos[nombre]++
if fallo {
agentFallos[nombre]++
}
}
// EstadoHerramientas devuelve el resumen ordenado por cantidad de fallos.
func EstadoHerramientas() []map[string]interface{} {
agentStatsMu.Lock()
defer agentStatsMu.Unlock()
out := make([]map[string]interface{}, 0, len(agentUsos))
for nombre, usos := range agentUsos {
out = append(out, map[string]interface{}{
"herramienta": nombre,
"usos": usos,
"fallos": agentFallos[nombre],
})
}
sort.Slice(out, func(i, j int) bool {
fi, fj := out[i]["fallos"].(int), out[j]["fallos"].(int)
if fi != fj {
return fi > fj
}
return out[i]["usos"].(int) > out[j]["usos"].(int)
})
return out
}
+193
View File
@@ -0,0 +1,193 @@
package services
import (
"archive/zip"
"bytes"
"compress/zlib"
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
"unicode"
)
// ExtraerTextoDeArchivo saca el texto de un archivo cualquiera para dárselo al
// agente. Es el equivalente de Whisper para audio y OCR para imágenes, pero
// para documentos.
//
// agenteID identifica a quién cobrarle si hace falta pasar por OCR; 0 = no medir.
func ExtraerTextoDeArchivo(agenteID uint, nombreArchivo string, datos []byte) (string, error) {
ext := strings.ToLower(filepath.Ext(nombreArchivo))
switch ext {
case ".txt", ".md", ".csv", ".json", ".xml", ".log", ".html", ".htm":
return string(datos), nil
case ".docx":
return textoDeDocx(datos)
case ".pdf":
return textoDePDF(agenteID, datos)
case ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff":
return ExtraerTextoOCR(agenteID, datos, mimeDeImagen(ext))
default:
return "", fmt.Errorf("no sé leer archivos %s; probá con PDF, Word (.docx), texto o una imagen", ext)
}
}
func mimeDeImagen(ext string) string {
switch ext {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".tif", ".tiff":
return "image/tiff"
default:
return "image/" + strings.TrimPrefix(ext, ".")
}
}
var etiquetaXML = regexp.MustCompile(`<[^>]+>`)
// textoDeDocx lee word/document.xml del .docx y lo aplana a texto. No pretende
// conservar el formato: el agente solo necesita el contenido y el orden.
func textoDeDocx(datos []byte) (string, error) {
zr, err := zip.NewReader(bytes.NewReader(datos), int64(len(datos)))
if err != nil {
return "", fmt.Errorf("el .docx no se pudo abrir: %w", err)
}
for _, f := range zr.File {
if f.Name != "word/document.xml" {
continue
}
rc, err := f.Open()
if err != nil {
return "", err
}
defer rc.Close()
xmlBytes, err := io.ReadAll(io.LimitReader(rc, 8<<20))
if err != nil {
return "", err
}
s := string(xmlBytes)
// Un párrafo, un salto de línea explícito y un fin de fila valen como
// salto; las celdas se separan con tab. El resto de etiquetas se tira.
s = strings.NewReplacer("</w:p>", "\n", "<w:br/>", "\n", "</w:tr>", "\n", "</w:tc>", "\t").Replace(s)
s = etiquetaXML.ReplaceAllString(s, "")
s = strings.NewReplacer("&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", `"`, "&apos;", "'").Replace(s)
return strings.TrimSpace(s), nil
}
return "", fmt.Errorf("el archivo no parece un .docx (no tiene word/document.xml)")
}
// textoDePDF saca el texto de un PDF digital (facturas, cotizaciones, cualquier
// cosa exportada por un programa) leyendo los operadores de texto de sus
// streams. Si el PDF es un escaneo no hay texto que leer, y ahí cae al OCR.
//
// ponytail: parser mínimo — entiende streams FlateDecode y los operadores Tj/TJ,
// que es lo que usan los PDFs generados por software. No maneja fuentes con
// codificaciones raras ni CID; para esos casos el fallback a OCR es la salida.
func textoDePDF(agenteID uint, datos []byte) (string, error) {
texto := strings.TrimSpace(textoDeStreamsPDF(datos))
// Un PDF escaneado devuelve nada o cuatro letras sueltas de un encabezado.
if len([]rune(texto)) >= 40 {
return texto, nil
}
ocrTexto, err := ExtraerTextoOCR(agenteID, datos, "application/pdf")
if err != nil {
if texto != "" {
return texto, nil
}
return "", fmt.Errorf("el PDF no tiene texto legible y el OCR no pudo procesarlo: %w", err)
}
return ocrTexto, nil
}
var streamRe = regexp.MustCompile(`(?s)stream\r?\n(.*?)endstream`)
func textoDeStreamsPDF(datos []byte) string {
var out strings.Builder
for _, m := range streamRe.FindAllSubmatch(datos, -1) {
crudo := m[1]
contenido := crudo
if zr, err := zlib.NewReader(bytes.NewReader(crudo)); err == nil {
if inflado, err := io.ReadAll(io.LimitReader(zr, 16<<20)); err == nil {
contenido = inflado
}
zr.Close()
}
if !bytes.Contains(contenido, []byte("Tj")) && !bytes.Contains(contenido, []byte("TJ")) {
continue
}
out.WriteString(textoDeContenidoPDF(contenido))
}
return out.String()
}
// textoDeContenidoPDF junta las cadenas entre paréntesis de un content stream,
// que es donde vive el texto visible, y respeta los saltos de línea (T*, TD, Td).
func textoDeContenidoPDF(contenido []byte) string {
var out strings.Builder
for i := 0; i < len(contenido); i++ {
switch contenido[i] {
case '(':
var s strings.Builder
for i++; i < len(contenido); i++ {
c := contenido[i]
if c == '\\' && i+1 < len(contenido) {
i++
switch contenido[i] {
case 'n':
s.WriteByte('\n')
case 't':
s.WriteByte('\t')
case 'r':
default:
s.WriteByte(contenido[i])
}
continue
}
if c == ')' {
break
}
s.WriteByte(c)
}
out.WriteString(s.String())
case 'T':
// T* / Td / TD mueven el cursor a otra línea.
if i+1 < len(contenido) {
switch contenido[i+1] {
case '*', 'd', 'D':
out.WriteByte('\n')
}
}
}
}
return limpiarNoImprimibles(out.String())
}
func limpiarNoImprimibles(s string) string {
return strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || unicode.IsPrint(r) {
return r
}
return -1
}, s)
}
// TextoDeArchivoParaAgente arma el mensaje que ve el agente cuando alguien le
// manda un documento: el contenido solo, sin contexto, hace que el modelo
// conteste como si el cliente hubiera escrito una factura.
func TextoDeArchivoParaAgente(nombreArchivo, caption, contenido string) string {
if len(contenido) > 30000 {
contenido = contenido[:30000] + "\n…(archivo recortado)"
}
var b strings.Builder
b.WriteString("El cliente adjuntó un archivo")
if nombreArchivo != "" {
b.WriteString(" llamado \"" + nombreArchivo + "\"")
}
b.WriteString(".")
if strings.TrimSpace(caption) != "" {
b.WriteString(" Escribió junto al archivo: " + strings.TrimSpace(caption))
}
b.WriteString("\n\nContenido del archivo:\n---\n" + strings.TrimSpace(contenido) + "\n---")
return b.String()
}
+68
View File
@@ -0,0 +1,68 @@
package services
import (
"bytes"
"compress/zlib"
"strconv"
"strings"
"testing"
)
// pdfDePrueba arma un PDF mínimo con el content stream comprimido, igual que
// los que genera cualquier programa que exporta a PDF.
func pdfDePrueba(t *testing.T, contenido string) []byte {
t.Helper()
var comp bytes.Buffer
zw := zlib.NewWriter(&comp)
if _, err := zw.Write([]byte(contenido)); err != nil {
t.Fatal(err)
}
zw.Close()
var pdf bytes.Buffer
pdf.WriteString("%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n")
pdf.WriteString("2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n")
pdf.WriteString("3 0 obj<</Type/Page/Parent 2 0 R/Contents 4 0 R>>endobj\n")
pdf.WriteString("4 0 obj<</Length " + strconv.Itoa(comp.Len()) + "/Filter/FlateDecode>>stream\n")
pdf.Write(comp.Bytes())
pdf.WriteString("\nendstream endobj\ntrailer<</Root 1 0 R>>\n%%EOF")
return pdf.Bytes()
}
func TestExtraerTextoDeArchivoPDFDigital(t *testing.T) {
contenido := `BT /F1 12 Tf 72 720 Td (FACTURA DE VENTA No. 1042) Tj T* ` +
`(Cliente: Acme SAS NIT 900.123.456-7) Tj T* (Total: \$1.500.000 COP) Tj ET`
got, err := ExtraerTextoDeArchivo(0, "factura.pdf", pdfDePrueba(t, contenido))
if err != nil {
t.Fatalf("ExtraerTextoDeArchivo: %v", err)
}
for _, quiero := range []string{"FACTURA DE VENTA No. 1042", "Acme SAS", "NIT 900.123.456-7", "$1.500.000 COP"} {
if !strings.Contains(got, quiero) {
t.Errorf("falta %q en el texto extraído:\n%s", quiero, got)
}
}
// T* separa renglones: sin eso la factura llega al agente como un chorizo.
if lineas := strings.Count(strings.TrimSpace(got), "\n"); lineas < 2 {
t.Errorf("esperaba al menos 3 renglones, hay %d:\n%s", lineas+1, got)
}
}
func TestExtraerTextoDeArchivoTexto(t *testing.T) {
got, err := ExtraerTextoDeArchivo(0, "notas.txt", []byte("hola\nmundo"))
if err != nil || got != "hola\nmundo" {
t.Errorf("got %q, err %v", got, err)
}
if _, err := ExtraerTextoDeArchivo(0, "cosa.exe", []byte("x")); err == nil {
t.Error("una extensión desconocida debería devolver error")
}
}
func TestTextoDeArchivoParaAgente(t *testing.T) {
got := TextoDeArchivoParaAgente("factura.pdf", "me cobraron de más", "Total: 1000")
for _, quiero := range []string{"factura.pdf", "me cobraron de más", "Total: 1000"} {
if !strings.Contains(got, quiero) {
t.Errorf("falta %q en:\n%s", quiero, got)
}
}
}
+57
View File
@@ -0,0 +1,57 @@
package services
import (
"fmt"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// GenerarArquitectura produce el PDF de una propuesta técnica a partir de un
// requerimiento en texto libre. Quien llama (Claude) ya consultó
// listar_arquitecturas_referencia para reutilizar patrones ya resueltos en vez de
// improvisar, y trae la propuesta ya armada; el backend solo la renderiza a PDF y,
// si aplica, la deja guardada como nueva referencia reutilizable.
func GenerarArquitectura(requerimiento, propuesta, nombre string, clienteID *uint, guardarComoReferencia bool, generadoPor string) (*models.DocumentoGenerado, error) {
if requerimiento == "" {
return nil, fmt.Errorf("requerimiento requerido")
}
if propuesta == "" {
return nil, fmt.Errorf("propuesta requerida")
}
if nombre == "" {
nombre = "Propuesta técnica"
}
var cliente *models.Cliente
if clienteID != nil {
c, err := models.GetClienteByID(*clienteID)
if err == nil {
cliente = c
}
}
datos := DatosBaseDocumento(map[string]interface{}{
"Nombre": nombre,
"Requerimiento": requerimiento,
"Propuesta": propuesta,
"Cliente": cliente,
})
doc, _, err := GenerarDocumento("arquitectura", datos, clienteID, nil, generadoPor)
if err != nil {
return nil, err
}
if guardarComoReferencia {
ref := &models.Arquitectura{
Nombre: nombre,
Descripcion: requerimiento,
ContenidoHTML: propuesta,
EsReferencia: true,
ClienteID: clienteID,
}
_ = models.CreateArquitectura(ref) // no bloquea la generación del documento si falla
}
return doc, nil
}
+42 -42
View File
@@ -134,18 +134,18 @@ type CFZoneAccount struct {
// CFZone representa una zona (dominio) en Cloudflare.
type CFZone struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Paused bool `json:"paused"`
Type string `json:"type"`
NameServers []string `json:"name_servers"`
OriginalNS []string `json:"original_name_servers"`
CreatedOn string `json:"created_on"`
ModifiedOn string `json:"modified_on"`
ActivatedOn string `json:"activated_on"`
Account CFZoneAccount `json:"account"`
Plan CFPlan `json:"plan"`
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Paused bool `json:"paused"`
Type string `json:"type"`
NameServers []string `json:"name_servers"`
OriginalNS []string `json:"original_name_servers"`
CreatedOn string `json:"created_on"`
ModifiedOn string `json:"modified_on"`
ActivatedOn string `json:"activated_on"`
Account CFZoneAccount `json:"account"`
Plan CFPlan `json:"plan"`
}
// CFPlan representa el plan de una zona.
@@ -162,30 +162,30 @@ type CFDNSRecordSettings struct {
// CFDNSRecord representa un registro DNS.
type CFDNSRecord struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Comment string `json:"comment,omitempty"`
Tags []string `json:"tags,omitempty"`
Settings CFDNSRecordSettings `json:"settings,omitempty"`
PrivateRouting bool `json:"private_routing,omitempty"`
Proxied bool `json:"proxied"`
Proxiable bool `json:"proxiable"`
TTL int `json:"ttl"`
Priority int `json:"priority,omitempty"`
CreatedOn string `json:"created_on"`
ModifiedOn string `json:"modified_on"`
CommentModifiedOn string `json:"comment_modified_on,omitempty"`
TagsModifiedOn string `json:"tags_modified_on,omitempty"`
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Comment string `json:"comment,omitempty"`
Tags []string `json:"tags,omitempty"`
Settings CFDNSRecordSettings `json:"settings,omitempty"`
PrivateRouting bool `json:"private_routing,omitempty"`
Proxied bool `json:"proxied"`
Proxiable bool `json:"proxiable"`
TTL int `json:"ttl"`
Priority int `json:"priority,omitempty"`
CreatedOn string `json:"created_on"`
ModifiedOn string `json:"modified_on"`
CommentModifiedOn string `json:"comment_modified_on,omitempty"`
TagsModifiedOn string `json:"tags_modified_on,omitempty"`
}
// CFSSLStatus representa un certificate pack de una zona.
type CFSSLStatus struct {
ID string `json:"id"`
Type string `json:"type"` // universal, advanced, custom, sni_custom
Type string `json:"type"` // universal, advanced, custom, sni_custom
Hosts []string `json:"hosts"`
Status string `json:"status"` // active, pending_validation, deleted
Status string `json:"status"` // active, pending_validation, deleted
ValidationMethod string `json:"validation_method,omitempty"`
ValidityDays int `json:"validity_days,omitempty"`
CertificateAuthority string `json:"certificate_authority,omitempty"`
@@ -243,10 +243,10 @@ type CFTokenVerify struct {
// CFTokenPolicy representa una política de permisos del token.
type CFTokenPolicy struct {
ID string `json:"id"`
Effect string `json:"effect"`
Resources map[string]string `json:"resources"`
PermGroups []CFTokenPermGroup `json:"permission_groups"`
ID string `json:"id"`
Effect string `json:"effect"`
Resources map[string]string `json:"resources"`
PermGroups []CFTokenPermGroup `json:"permission_groups"`
}
// CFTokenPermGroup un grupo de permisos.
@@ -478,14 +478,14 @@ func (c *CloudflareClient) doRequest(method, path string, payload interface{}, d
// CFDNSRecordInput es el payload para crear o actualizar un registro DNS.
type CFDNSRecordInput struct {
Type string `json:"type"` // A, AAAA, CNAME, TXT, MX, NS, SRV, CAA…
Name string `json:"name"` // Nombre del registro (ej. "www" o "@")
Content string `json:"content"` // Valor del registro
TTL int `json:"ttl"` // 1 = automático, o segundos (min 60)
Proxied bool `json:"proxied"` // true = nube naranja
Priority int `json:"priority,omitempty"` // Solo para MX / SRV
Comment string `json:"comment,omitempty"` // Comentario descriptivo
Tags []string `json:"tags,omitempty"` // Etiquetas (ej. ["owner:team"])
Type string `json:"type"` // A, AAAA, CNAME, TXT, MX, NS, SRV, CAA…
Name string `json:"name"` // Nombre del registro (ej. "www" o "@")
Content string `json:"content"` // Valor del registro
TTL int `json:"ttl"` // 1 = automático, o segundos (min 60)
Proxied bool `json:"proxied"` // true = nube naranja
Priority int `json:"priority,omitempty"` // Solo para MX / SRV
Comment string `json:"comment,omitempty"` // Comentario descriptivo
Tags []string `json:"tags,omitempty"` // Etiquetas (ej. ["owner:team"])
Settings *CFDNSRecordSettings `json:"settings,omitempty"` // Configuraciones adicionales
}
@@ -0,0 +1,79 @@
package services
import (
"fmt"
"time"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// GenerarDocumentoContrato produce el PDF de un contrato ya existente a partir de
// la plantilla activa tipo 'contrato' (cláusulas estándar) + los datos del contrato
// (cliente, servicios, vigencia, valor). Es la implementación única que comparten
// el endpoint POST /api/v2/contratos/:id/generar-documento y la tool crear_contrato.
func GenerarDocumentoContrato(contratoID uint, generadoPor string) (*models.DocumentoGenerado, error) {
contrato, err := models.GetContratoByID(contratoID)
if err != nil {
return nil, fmt.Errorf("contrato no encontrado: %w", err)
}
datos := DatosBaseDocumento(map[string]interface{}{
"Contrato": contrato,
"Cliente": contrato.Cliente,
"Servicios": contrato.Servicios,
"FechaInicio": contrato.FechaInicio.Format("02/01/2006"),
"FechaVencimiento": contrato.FechaVencimiento.Format("02/01/2006"),
"PrecioAcordado": contrato.PrecioAcordado,
"Moneda": contrato.Moneda,
"Notas": contrato.Notas,
})
doc, _, err := GenerarDocumento("contrato", datos, &contrato.ClienteID, nil, generadoPor)
return doc, err
}
// CrearContratoConDocumento crea el registro de contrato y de una vez genera su PDF
// con cláusulas estándar, para el flujo "cliente, tipo de servicio, duración" que
// describe la automatización con IA (Telegram / chat propio / Claude directo).
func CrearContratoConDocumento(clienteID uint, servicioIDs []uint, duracionMeses int, precioAcordado float64, moneda, notas, generadoPor string) (*models.Contrato, *models.DocumentoGenerado, error) {
if clienteID == 0 {
return nil, nil, fmt.Errorf("cliente_id requerido")
}
if len(servicioIDs) == 0 {
return nil, nil, fmt.Errorf("servicio_ids requerido (al menos un servicio)")
}
if duracionMeses <= 0 {
duracionMeses = 12
}
if moneda == "" {
moneda = "COP"
}
inicio := time.Now()
vencimiento := inicio.AddDate(0, duracionMeses, 0)
c := models.Contrato{
ClienteID: clienteID,
FechaInicio: inicio,
FechaVencimiento: vencimiento,
PrecioAcordado: precioAcordado,
Moneda: moneda,
Estado: "activo",
Notas: notas,
}
if err := models.CreateContrato(c, servicioIDs, nil, nil, nil); err != nil {
return nil, nil, fmt.Errorf("no se pudo crear el contrato: %w", err)
}
// Recargar con Cliente/Servicios precargados (CreateContrato no los devuelve).
creado, err := models.GetUltimoContratoByCliente(clienteID)
if err != nil {
return nil, nil, fmt.Errorf("contrato creado pero no se pudo recargar: %w", err)
}
doc, err := GenerarDocumentoContrato(creado.ID, generadoPor)
if err != nil {
return creado, nil, err
}
return creado, doc, nil
}

Some files were not shown because too many files have changed in this diff Show More