Compare commits
116
Commits
5607836261
...
main
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"--prefix",
|
||||||
|
"orchestrator",
|
||||||
|
"run",
|
||||||
|
"dev",
|
||||||
|
"--",
|
||||||
|
"--port",
|
||||||
|
"3000",
|
||||||
|
"--strictPort"
|
||||||
|
],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -6,3 +6,12 @@ DB_PORT=5432
|
|||||||
DB_USER=postgres
|
DB_USER=postgres
|
||||||
DB_PASS=postgres
|
DB_PASS=postgres
|
||||||
DB_NAME=casbin
|
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
@@ -4,7 +4,8 @@ main
|
|||||||
/tmp
|
/tmp
|
||||||
/node_modules/
|
/node_modules/
|
||||||
*.fiber.gz
|
*.fiber.gz
|
||||||
#.env
|
.env
|
||||||
|
.env.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dump.rdb
|
dump.rdb
|
||||||
#uploads
|
#uploads
|
||||||
@@ -17,4 +18,5 @@ stats/
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
package-lock.json
|
package-lock.json
|
||||||
.task-project
|
.task-project
|
||||||
*.db
|
*.db
|
||||||
|
session.db
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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><script src="https://admin.u-site.app/widget/umind.js" data-site="SITE_KEY" defer></script></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>
|
||||||
@@ -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><script src="https://admin.u-site.app/widget/umind.js" data-site="SITE_KEY" defer></script></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></body></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>
|
||||||
@@ -51,6 +51,14 @@ token:
|
|||||||
app_jwt_secret: SECRET_APP
|
app_jwt_secret: SECRET_APP
|
||||||
api_jwt_secret: SECRET_API
|
api_jwt_secret: SECRET_API
|
||||||
expires_in: 31536000
|
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:
|
jwt:
|
||||||
app:
|
app:
|
||||||
secret: SECRET_APP
|
secret: SECRET_APP
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ token:
|
|||||||
app_jwt_secret: SECRET_APP
|
app_jwt_secret: SECRET_APP
|
||||||
api_jwt_secret: SECRET_API
|
api_jwt_secret: SECRET_API
|
||||||
expires_in: 31536000
|
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:
|
jwt:
|
||||||
app:
|
app:
|
||||||
secret: SECRET_APP
|
secret: SECRET_APP
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type AppConfig struct {
|
|||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"`
|
||||||
Log LogConfig `yaml:"log"`
|
Log LogConfig `yaml:"log"`
|
||||||
Token Token `yaml:"token"`
|
Token Token `yaml:"token"`
|
||||||
|
OAuth OAuthConfig `yaml:"oauth"`
|
||||||
Profiler ProfilerConfig `yaml:"profiler"`
|
Profiler ProfilerConfig `yaml:"profiler"`
|
||||||
Flash *flash.Flash
|
Flash *flash.Flash
|
||||||
ConfigFile string
|
ConfigFile string
|
||||||
@@ -40,6 +41,7 @@ func (cfg *AppConfig) Setup() {
|
|||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
cfg.VerificarSecretos()
|
||||||
cfg.Server.LoadPath()
|
cfg.Server.LoadPath()
|
||||||
cfg.View.Load(cfg.Server.Path)
|
cfg.View.Load(cfg.Server.Path)
|
||||||
cfg.Mail.View = &cfg.View
|
cfg.Mail.View = &cfg.View
|
||||||
@@ -94,6 +96,8 @@ func (cfg *AppConfig) LoadComponents() {
|
|||||||
|
|
||||||
func (cfg *AppConfig) LoadStatic() {
|
func (cfg *AppConfig) LoadStatic() {
|
||||||
cfg.Server.Static("/websocket", "./resources/views/websocket.html")
|
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{
|
cfg.Server.Static("/uploads", "./uploads", fiber.Static{
|
||||||
ByteRange: true,
|
ByteRange: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
@@ -9,13 +9,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Token struct {
|
type Token struct {
|
||||||
Hash string `json:"token"`
|
Hash string `json:"token"`
|
||||||
Expire int64 `mapstructure:"JWT_EXPIRE" json:"expires_in" yaml:"expires_in"`
|
Expire int64 `mapstructure:"JWT_EXPIRE" json:"expires_in" yaml:"expires_in"`
|
||||||
AppJwtSecret string `mapstructure:"APP_JWT_SECRET" yaml:"app_jwt_secret"`
|
// Los secretos JWT firman las cookies de sesión: si se filtran, cualquiera
|
||||||
ApiJwtSecret string `mapstructure:"API_JWT_SECRET" yaml:"api_jwt_secret"`
|
// 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) {
|
func (t *Token) CreateToken(c *fiber.Ctx, userID uint, secret string, expire ...int64) (*Token, error) {
|
||||||
token := jwt.New(jwt.SigningMethodHS256)
|
token := jwt.New(jwt.SigningMethodHS256)
|
||||||
|
|
||||||
@@ -47,7 +50,7 @@ func (t *Token) CreateToken(c *fiber.Ctx, userID uint, secret string, expire ...
|
|||||||
return t, nil
|
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) {
|
func (t *Token) ParseToken(c *fiber.Ctx, secret string) (uint, error) {
|
||||||
tokenString := c.Cookies("Verify-Rest-Token")
|
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
|
return uint(claims["id"].(float64)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//DeleteToken deletes the jwt token
|
// DeleteToken deletes the jwt token
|
||||||
func (t *Token) DeleteToken(c *fiber.Ctx) {
|
func (t *Token) DeleteToken(c *fiber.Ctx) {
|
||||||
c.ClearCookie("Verify-Rest-Token")
|
c.ClearCookie("Verify-Rest-Token")
|
||||||
}
|
}
|
||||||
|
|
||||||
//RefreshToken refreshes the token
|
// RefreshToken refreshes the token
|
||||||
func (t *Token) RefreshToken(c *fiber.Ctx, secret string) (*Token, error) {
|
func (t *Token) RefreshToken(c *fiber.Ctx, secret string) (*Token, error) {
|
||||||
u, err := t.ParseToken(c, secret)
|
u, err := t.ParseToken(c, secret)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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 {
|
||||||
|
// Los archivos de los espacios uMind NUNCA se sirven por el estático: son
|
||||||
|
// de un cliente concreto y el guard genérico solo sabe si hay sesión de
|
||||||
|
// panel, no de quién es el archivo. Salen por su endpoint, que sí valida
|
||||||
|
// propiedad. Cerrado por construcción y no por acordarse.
|
||||||
|
if strings.HasPrefix(c.Path(), "/uploads/umind/") {
|
||||||
|
return c.Status(fiber.StatusNotFound).SendString("Not found")
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -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": "<detalle>"}</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=<token></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>
|
||||||
@@ -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.
|
||||||
@@ -44,7 +44,9 @@ require (
|
|||||||
github.com/chai2010/webp v1.4.0
|
github.com/chai2010/webp v1.4.0
|
||||||
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe
|
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe
|
||||||
github.com/chromedp/chromedp v0.16.0
|
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/go-sql-driver/mysql v1.8.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/mattn/go-sqlite3 v1.14.22
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
github.com/microsoft/go-mssqldb v1.7.2
|
github.com/microsoft/go-mssqldb v1.7.2
|
||||||
@@ -54,12 +56,16 @@ require (
|
|||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/sirupsen/logrus v1.9.4
|
github.com/sirupsen/logrus v1.9.4
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
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
|
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/sqlite v1.5.6
|
||||||
gorm.io/driver/sqlserver v1.5.3
|
gorm.io/driver/sqlserver v1.5.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||||
@@ -71,6 +77,8 @@ require (
|
|||||||
github.com/clipperhouse/uax29/v2 v2.7.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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // 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/emicklei/go-restful/v3 v3.12.1 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
@@ -94,7 +102,6 @@ require (
|
|||||||
github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect
|
github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/gofuzz v1.2.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/gookit/filter v1.2.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
@@ -131,7 +138,6 @@ require (
|
|||||||
github.com/tinylib/msgp v1.6.1 // indirect
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
github.com/tklauser/numcpus v0.8.0 // indirect
|
github.com/tklauser/numcpus v0.8.0 // indirect
|
||||||
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // 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/valyala/tcplisten v1.0.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
@@ -143,8 +149,6 @@ require (
|
|||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // 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/sync v0.20.0 // indirect
|
||||||
golang.org/x/term v0.43.0 // indirect
|
golang.org/x/term v0.43.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
|
|||||||
@@ -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.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.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/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.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/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||||
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
|
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
|
||||||
@@ -173,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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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/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 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU=
|
||||||
github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
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=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ package main
|
|||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
"github.com/pyroscope-io/pyroscope/pkg/agent/profiler"
|
"github.com/pyroscope-io/pyroscope/pkg/agent/profiler"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
"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/migrations"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/auth"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/rest/routes"
|
"github.com/sujit-baniya/fiber-boilerplate/rest/routes"
|
||||||
@@ -33,6 +37,13 @@ func main() {
|
|||||||
// Actualizar versión del servidor
|
// Actualizar versión del servidor
|
||||||
app.Http.Server.Version = app.Version
|
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
|
// Habilitar CORS
|
||||||
app.Http.Server.App.Use(cors.New(cors.Config{
|
app.Http.Server.App.Use(cors.New(cors.Config{
|
||||||
AllowOrigins: "*",
|
AllowOrigins: "*",
|
||||||
@@ -50,8 +61,13 @@ func main() {
|
|||||||
migrations.MigratePortal()
|
migrations.MigratePortal()
|
||||||
// Crear tabla de Landing Generator
|
// Crear tabla de Landing Generator
|
||||||
migrations.MigrateLanding()
|
migrations.MigrateLanding()
|
||||||
// Crear tablas de integraciones y pasarelas si no existen
|
// Crear tablas de integraciones y pasarelas si no existen.
|
||||||
if err := app.Http.Database.DB.AutoMigrate(
|
// 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)
|
// Tablas base del sistema (columnas nuevas)
|
||||||
&models.Users{},
|
&models.Users{},
|
||||||
&models.Roles{},
|
&models.Roles{},
|
||||||
@@ -118,6 +134,9 @@ func main() {
|
|||||||
// Tablero de tareas
|
// Tablero de tareas
|
||||||
&models.Tarea{},
|
&models.Tarea{},
|
||||||
&models.TareaComentario{},
|
&models.TareaComentario{},
|
||||||
|
// Pagos externos: API de cobros para aplicaciones de terceros
|
||||||
|
&models.ServicioPagoExterno{},
|
||||||
|
&models.SolicitudPagoExterna{},
|
||||||
// Soporte: webhook de correo entrante
|
// Soporte: webhook de correo entrante
|
||||||
&models.SoporteWebhookConfig{},
|
&models.SoporteWebhookConfig{},
|
||||||
// Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA
|
// Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA
|
||||||
@@ -127,10 +146,32 @@ func main() {
|
|||||||
&models.Arquitectura{},
|
&models.Arquitectura{},
|
||||||
// Vinculación de Telegram para staff interno
|
// Vinculación de Telegram para staff interno
|
||||||
&models.TelegramStaffToken{},
|
&models.TelegramStaffToken{},
|
||||||
); err != nil {
|
// uMind: chat con IA embebible por tenant (widget web + RAG)
|
||||||
// AutoMigrate se detiene en el primer error: si esto falla, todos los
|
&models.UmindTenant{},
|
||||||
// modelos que venían después en la lista se quedan sin crear en silencio.
|
&models.UmindAgente{},
|
||||||
log.Printf("[MIGRATE] Error en AutoMigrate: %v", err)
|
&models.UmindDocumento{},
|
||||||
|
&models.UmindChunk{},
|
||||||
|
&models.UmindMensaje{},
|
||||||
|
&models.UmindHerramienta{},
|
||||||
|
&models.UmindCanal{},
|
||||||
|
&models.UmindAviso{},
|
||||||
|
&models.UmindArchivo{},
|
||||||
|
&models.UmindAccionPendiente{},
|
||||||
|
&models.UmindVigilancia{},
|
||||||
|
&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
|
// Seed automático (idempotente) de módulos del sistema
|
||||||
migrations.SeedRenovaciones()
|
migrations.SeedRenovaciones()
|
||||||
@@ -146,12 +187,34 @@ func main() {
|
|||||||
migrations.SeedShield()
|
migrations.SeedShield()
|
||||||
migrations.SeedPartnerRecursos()
|
migrations.SeedPartnerRecursos()
|
||||||
models.SeedContabilidad()
|
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.SeedContabilidadMenu()
|
||||||
migrations.SeedWebSms()
|
migrations.SeedWebSms()
|
||||||
migrations.SeedUrlMonitor()
|
migrations.SeedUrlMonitor()
|
||||||
migrations.SeedTareas()
|
migrations.SeedTareas()
|
||||||
|
migrations.SeedPagosExternos()
|
||||||
migrations.SeedAutomatizacionIA()
|
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)
|
// Cargar config SMTP desde BD (sobreescribe valores del .env/config.yml si hay registro activo)
|
||||||
if smtpCfg, err := models.GetSmtpConfig(); err == nil {
|
if smtpCfg, err := models.GetSmtpConfig(); err == nil {
|
||||||
app.Http.Mail.Host = smtpCfg.Host
|
app.Http.Mail.Host = smtpCfg.Host
|
||||||
@@ -171,6 +234,20 @@ func main() {
|
|||||||
defer services.DetenerCron()
|
defer services.DetenerCron()
|
||||||
// Cargar rutas
|
// Cargar rutas
|
||||||
routes.LoadRoutes(app.Http.Server.App)
|
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()
|
app.Http.Route404()
|
||||||
log.Fatal(app.Http.Server.ServeWithGraceFullShutdown())
|
log.Fatal(app.Http.Server.ServeWithGraceFullShutdown())
|
||||||
}
|
}
|
||||||
|
|||||||
+544
-82
@@ -3,6 +3,7 @@ package migrations
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
@@ -25,8 +26,11 @@ type Session struct {
|
|||||||
func Migrate() {
|
func Migrate() {
|
||||||
log.Println("Initiating migration...")
|
log.Println("Initiating migration...")
|
||||||
|
|
||||||
// Migrate the main application models
|
// Migrate the main application models.
|
||||||
if err := app.Http.Database.DB.Migrator().AutoMigrate(
|
// 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.Roles{},
|
||||||
&models.Users{},
|
&models.Users{},
|
||||||
&models.Modules{},
|
&models.Modules{},
|
||||||
@@ -104,6 +108,9 @@ func Migrate() {
|
|||||||
// Tablero de tareas
|
// Tablero de tareas
|
||||||
&models.Tarea{},
|
&models.Tarea{},
|
||||||
&models.TareaComentario{},
|
&models.TareaComentario{},
|
||||||
|
// 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
|
// Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA
|
||||||
&models.PlantillaDocumento{},
|
&models.PlantillaDocumento{},
|
||||||
&models.Tarifa{},
|
&models.Tarifa{},
|
||||||
@@ -111,8 +118,32 @@ func Migrate() {
|
|||||||
&models.Arquitectura{},
|
&models.Arquitectura{},
|
||||||
// Vinculación de Telegram para staff interno
|
// Vinculación de Telegram para staff interno
|
||||||
&models.TelegramStaffToken{},
|
&models.TelegramStaffToken{},
|
||||||
); err != nil {
|
// uMind: chat con IA embebible por tenant (widget web + RAG)
|
||||||
log.Fatalf("Error during main migration: %v", err)
|
&models.UmindTenant{},
|
||||||
|
&models.UmindAgente{},
|
||||||
|
&models.UmindDocumento{},
|
||||||
|
&models.UmindChunk{},
|
||||||
|
&models.UmindMensaje{},
|
||||||
|
&models.UmindHerramienta{},
|
||||||
|
&models.UmindCanal{},
|
||||||
|
&models.UmindAviso{},
|
||||||
|
&models.UmindArchivo{},
|
||||||
|
&models.UmindAccionPendiente{},
|
||||||
|
&models.UmindVigilancia{},
|
||||||
|
&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
|
// Open (or create) a SQLite database for sessions
|
||||||
@@ -163,6 +194,43 @@ func Migrate() {
|
|||||||
log.Println("Migration Completed...")
|
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"
|
// SeedStatuspage agrega el submódulo "Statuspage" (Atlassian) al módulo "Integraciones"
|
||||||
// y lo asigna a todos los roles. Es idempotente.
|
// y lo asigna a todos los roles. Es idempotente.
|
||||||
func SeedStatuspage() {
|
func SeedStatuspage() {
|
||||||
@@ -176,9 +244,15 @@ func SeedStatuspage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entries := []struct{ title, desc, url string }{
|
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
|
var insertados []models.Submodules
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
var sub models.Submodules
|
var sub models.Submodules
|
||||||
@@ -201,18 +275,15 @@ func SeedStatuspage() {
|
|||||||
insertados = append(insertados, sub)
|
insertados = append(insertados, sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
var roles []models.Roles
|
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||||
if err := db.Find(&roles).Error; err != nil {
|
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
|
||||||
log.Printf("[SEED] Error obteniendo roles: %v", err)
|
// — 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
|
return
|
||||||
}
|
}
|
||||||
for _, rol := range roles {
|
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("[SEED] Seed de Statuspage completado.")
|
log.Println("[SEED] Seed de Statuspage completado.")
|
||||||
}
|
}
|
||||||
@@ -407,7 +478,7 @@ func SeedRenovaciones() {
|
|||||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||||
} else {
|
} else {
|
||||||
db.Model(&rolAdmin).Association("Submodules").Append(&insertados)
|
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||||
log.Printf("[SEED] Submódulos de Renovaciones asignados al rol 'Administrador'")
|
log.Printf("[SEED] Submódulos de Renovaciones asignados al rol 'Administrador'")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,7 +552,7 @@ func SeedAutomatizacionIA() {
|
|||||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||||
} else {
|
} else {
|
||||||
db.Model(&rolAdmin).Association("Submodules").Append(&insertados)
|
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||||
log.Printf("[SEED] Submódulos de Automatización IA asignados al rol 'Administrador'")
|
log.Printf("[SEED] Submódulos de Automatización IA asignados al rol 'Administrador'")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,6 +587,8 @@ func SeedIntegraciones() {
|
|||||||
{"Hostinger", "Panel de VPS, dominios, hosting y DNS de Hostinger", "/app/hostinger"},
|
{"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"},
|
{"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"},
|
{"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
|
var insertados []models.Submodules
|
||||||
@@ -549,18 +622,15 @@ func SeedIntegraciones() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Asignar a todos los roles existentes
|
// 3. Asignar a todos los roles existentes
|
||||||
var roles []models.Roles
|
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||||
if err := db.Find(&roles).Error; err != nil {
|
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
|
||||||
log.Printf("[SEED] Error obteniendo roles: %v", err)
|
// — 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
|
return
|
||||||
}
|
}
|
||||||
for _, rol := range roles {
|
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("[SEED] Seed de Integraciones completado.")
|
log.Println("[SEED] Seed de Integraciones completado.")
|
||||||
}
|
}
|
||||||
@@ -603,18 +673,15 @@ func SeedPasarelas() {
|
|||||||
insertados = append(insertados, sub)
|
insertados = append(insertados, sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
var roles []models.Roles
|
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||||
if err := db.Find(&roles).Error; err != nil {
|
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
|
||||||
log.Printf("[SEED] Error obteniendo roles: %v", err)
|
// — 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
|
return
|
||||||
}
|
}
|
||||||
for _, rol := range roles {
|
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("[SEED] Seed de Pasarelas completado.")
|
log.Println("[SEED] Seed de Pasarelas completado.")
|
||||||
}
|
}
|
||||||
@@ -680,18 +747,15 @@ func SeedServidores() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Asignar a todos los roles existentes
|
// 3. Asignar a todos los roles existentes
|
||||||
var roles []models.Roles
|
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||||
if err := db.Find(&roles).Error; err != nil {
|
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
|
||||||
log.Printf("[SEED] Error obteniendo roles: %v", err)
|
// — 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
|
return
|
||||||
}
|
}
|
||||||
for _, rol := range roles {
|
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("[SEED] Seed de Servidores completado.")
|
log.Println("[SEED] Seed de Servidores completado.")
|
||||||
}
|
}
|
||||||
@@ -751,18 +815,15 @@ func SeedAdministracion() {
|
|||||||
insertados = append(insertados, sub)
|
insertados = append(insertados, sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
var roles []models.Roles
|
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||||
if err := db.Find(&roles).Error; err != nil {
|
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
|
||||||
log.Printf("[SEED] Error obteniendo roles: %v", err)
|
// — 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
|
return
|
||||||
}
|
}
|
||||||
for _, rol := range roles {
|
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("[SEED] Seed de Administración completado.")
|
log.Println("[SEED] Seed de Administración completado.")
|
||||||
}
|
}
|
||||||
@@ -806,18 +867,15 @@ func SeedSaas() {
|
|||||||
insertados = append(insertados, sub)
|
insertados = append(insertados, sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
var roles []models.Roles
|
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||||
if err := db.Find(&roles).Error; err != nil {
|
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
|
||||||
log.Printf("[SEED] Error obteniendo roles: %v", err)
|
// — 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
|
return
|
||||||
}
|
}
|
||||||
for _, rol := range roles {
|
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("[SEED] Seed de SaaS completado.")
|
log.Println("[SEED] Seed de SaaS completado.")
|
||||||
}
|
}
|
||||||
@@ -883,13 +941,15 @@ func SeedTelegram() {
|
|||||||
log.Printf("[SEED] Submódulo 'Telegram' ya existe (ID %d)", sub.ID)
|
log.Printf("[SEED] Submódulo 'Telegram' ya existe (ID %d)", sub.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
var roles []models.Roles
|
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||||
if err := db.Find(&roles).Error; err != nil {
|
// 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
|
return
|
||||||
}
|
}
|
||||||
for _, rol := range roles {
|
asignarSubmodulosSiFaltan(&rolAdmin, []models.Submodules{sub})
|
||||||
db.Model(&rol).Association("Submodules").Append(&sub)
|
|
||||||
}
|
|
||||||
log.Println("[SEED] Seed de Telegram completado.")
|
log.Println("[SEED] Seed de Telegram completado.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,13 +1001,15 @@ func SeedPortalClientes() {
|
|||||||
insertados = append(insertados, sub)
|
insertados = append(insertados, sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
var roles []models.Roles
|
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||||
if err := db.Find(&roles).Error; err != nil {
|
// 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
|
return
|
||||||
}
|
}
|
||||||
for _, rol := range roles {
|
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||||
db.Model(&rol).Association("Submodules").Append(&insertados)
|
|
||||||
}
|
|
||||||
log.Println("[SEED] Seed de Portal de Clientes completado.")
|
log.Println("[SEED] Seed de Portal de Clientes completado.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1030,7 +1092,7 @@ func SeedPartnerRecursos() {
|
|||||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||||
} else {
|
} 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.Printf("[SEED] Submódulo 'Partner Recursos' asignado al rol 'Administrador'")
|
||||||
}
|
}
|
||||||
log.Println("[SEED] Seed de Partner Recursos completado.")
|
log.Println("[SEED] Seed de Partner Recursos completado.")
|
||||||
@@ -1084,7 +1146,7 @@ func SeedContabilidadMenu() {
|
|||||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||||
} else {
|
} else {
|
||||||
db.Model(&rolAdmin).Association("Submodules").Append(&insertados)
|
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||||
log.Printf("[SEED] Submódulos de Contabilidad asignados al rol 'Administrador'")
|
log.Printf("[SEED] Submódulos de Contabilidad asignados al rol 'Administrador'")
|
||||||
}
|
}
|
||||||
log.Println("[SEED] Seed de Contabilidad completado.")
|
log.Println("[SEED] Seed de Contabilidad completado.")
|
||||||
@@ -1121,7 +1183,7 @@ func SeedWebSms() {
|
|||||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||||
} else {
|
} 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.Printf("[SEED] Submódulo 'WebSMS' asignado al rol 'Administrador'")
|
||||||
}
|
}
|
||||||
log.Println("[SEED] Seed de WebSMS completado.")
|
log.Println("[SEED] Seed de WebSMS completado.")
|
||||||
@@ -1157,7 +1219,7 @@ func SeedUrlMonitor() {
|
|||||||
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
|
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
|
||||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||||
} else {
|
} 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.Printf("[SEED] Submódulo 'Monitor de URLs' asignado al rol 'Administrador'")
|
||||||
}
|
}
|
||||||
log.Println("[SEED] Seed de Monitor de URLs completado.")
|
log.Println("[SEED] Seed de Monitor de URLs completado.")
|
||||||
@@ -1193,8 +1255,408 @@ func SeedTareas() {
|
|||||||
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
|
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
|
||||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||||
} else {
|
} 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.Printf("[SEED] Submódulo 'Tareas' asignado al rol 'Administrador'")
|
||||||
}
|
}
|
||||||
log.Println("[SEED] Seed de Tareas completado.")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { api } from '../lib/api.js'
|
||||||
|
import { apiUmind } from '../lib/contexto.js'
|
||||||
|
|
||||||
|
// La barra del espacio: lo que es del negocio y no de un agente puntual.
|
||||||
|
const props = defineProps({ tenantId: { type: [String, Number], required: true } })
|
||||||
|
|
||||||
|
const pendientes = ref(0)
|
||||||
|
|
||||||
|
async function cargarPendientes() {
|
||||||
|
try {
|
||||||
|
const r = await api.get(apiUmind(`/umind/acciones?tenant_id=${props.tenantId}`))
|
||||||
|
pendientes.value = r.pendientes || 0
|
||||||
|
} catch {
|
||||||
|
pendientes.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(() => props.tenantId, cargarPendientes, { immediate: true })
|
||||||
|
|
||||||
|
const SECCIONES = [
|
||||||
|
['tenant-agentes', '', 'Agentes'],
|
||||||
|
['pendientes', '/pendientes', 'Pendientes'],
|
||||||
|
['archivos', '/archivos', 'Archivos'],
|
||||||
|
['plantillas', '/plantillas', 'Plantillas'],
|
||||||
|
['uso', '/uso', 'Consumo'],
|
||||||
|
['ai-propia', '/ia', 'Tu IA'],
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="flex gap-1 flex-wrap mb-6 border-b border-borde pb-2">
|
||||||
|
<router-link
|
||||||
|
v-for="[nombre, sufijo, label] in SECCIONES"
|
||||||
|
:key="nombre"
|
||||||
|
:to="`/tenants/${tenantId}${sufijo}`"
|
||||||
|
class="px-3 py-1.5 rounded-lg text-sm transition-colors inline-flex items-center gap-1.5"
|
||||||
|
:class="$route.name === nombre
|
||||||
|
? 'bg-brand/10 text-brand font-medium'
|
||||||
|
: 'text-tenue hover:text-texto hover:bg-elevado'"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
<!-- El badge solo aparece cuando hay algo esperando: un cero permanente
|
||||||
|
enseña a ignorar el lugar donde después va lo importante. -->
|
||||||
|
<span
|
||||||
|
v-if="nombre === 'pendientes' && pendientes > 0"
|
||||||
|
class="px-1.5 py-0.5 rounded-full bg-amber-500 text-white text-[10px] font-semibold leading-none"
|
||||||
|
>{{ pendientes }}</span>
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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 esFormData = options.body instanceof FormData
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...options,
|
||||||
|
headers: esFormData
|
||||||
|
? 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),
|
||||||
|
// Sin Content-Type a mano: en un multipart el boundary lo pone el navegador,
|
||||||
|
// y fijarlo nosotros deja el body ilegible del lado del servidor.
|
||||||
|
postForm: (path, formData) => request(path, { method: 'POST', body: formData, headers: {} }),
|
||||||
|
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' }),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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')
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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 Pendientes from './views/Pendientes.vue'
|
||||||
|
import Archivos from './views/Archivos.vue'
|
||||||
|
import Plantillas from './views/Plantillas.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/:id/pendientes', name: 'pendientes', component: Pendientes, props: true },
|
||||||
|
{ path: '/tenants/:id/archivos', name: 'archivos', component: Archivos, props: true },
|
||||||
|
{ path: '/tenants/:id/plantillas', name: 'plantillas', component: Plantillas, props: true },
|
||||||
|
{
|
||||||
|
path: '/tenants/:tenantId/agentes/:agenteId',
|
||||||
|
name: 'agente-detail',
|
||||||
|
component: AgenteDetail,
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,672 @@
|
|||||||
|
<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 TabAcciones from './agente/TabAcciones.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'
|
||||||
|
import PrimerosPasos from './agente/PrimerosPasos.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é.
|
||||||
|
// Ver qué se leyó de una fuente, fragmento por fragmento. Para un sitio
|
||||||
|
// crawleado es la única ventana: no tiene Editar (se rehace crawleando) y
|
||||||
|
// hasta ahora "9 fragmentos" era una caja negra.
|
||||||
|
const viendoDoc = ref(null)
|
||||||
|
const fragmentos = ref([])
|
||||||
|
const cargandoFragmentos = ref(false)
|
||||||
|
|
||||||
|
async function verFragmentos(d) {
|
||||||
|
viendoDoc.value = d
|
||||||
|
fragmentos.value = []
|
||||||
|
cargandoFragmentos.value = true
|
||||||
|
try {
|
||||||
|
const r = await api.get(apiUmind(`/umind/documentos/${d.ID}/fragmentos`))
|
||||||
|
fragmentos.value = r.items || []
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
viendoDoc.value = null
|
||||||
|
} finally {
|
||||||
|
cargandoFragmentos.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'],
|
||||||
|
['acciones', 'Lo que hace'],
|
||||||
|
['canales', 'Dónde atiende'],
|
||||||
|
]
|
||||||
|
|
||||||
|
// "Lo que sabe" agrupa todo lo que alimenta las respuestas: la información
|
||||||
|
// cargada, las casillas que puede consultar y las herramientas que puede
|
||||||
|
// llamar. Son tres formas de saber, no tres pantallas sueltas.
|
||||||
|
const SUBSECCIONES_SABE = [
|
||||||
|
['conocimiento', 'Información'],
|
||||||
|
['conexiones', 'Cuentas de correo'],
|
||||||
|
['herramientas', 'Herramientas'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const enLoQueSabe = computed(() => SUBSECCIONES_SABE.some(([k]) => k === tab.value))
|
||||||
|
|
||||||
|
const AVANZADO = [['auditoria', 'Problemas']]
|
||||||
|
|
||||||
|
// La guía se muestra hasta que sabe algo Y atiende en algún lado. Probar es
|
||||||
|
// deseable pero no bloquea: nadie queda preso de la guía por no haber chateado.
|
||||||
|
const configurando = computed(
|
||||||
|
() => !documentos.value.some((d) => d.estado === 'listo') || !canales.value.some((c) => c.activo),
|
||||||
|
)
|
||||||
|
|
||||||
|
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">← 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>
|
||||||
|
|
||||||
|
<!-- Mientras el agente esté a medio configurar, la guía de puesta en
|
||||||
|
marcha; después, el estado de todos los días. Nunca los dos: dos
|
||||||
|
resúmenes a la vez es no tener ninguno. -->
|
||||||
|
<PrimerosPasos
|
||||||
|
v-if="agente && configurando"
|
||||||
|
:documentos="documentos"
|
||||||
|
:canales="canales"
|
||||||
|
:sesiones="sesiones"
|
||||||
|
@ir="irA"
|
||||||
|
/>
|
||||||
|
<EstadoAgente
|
||||||
|
v-else-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="(key === 'conocimiento' ? enLoQueSabe : 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="enLoQueSabe" class="flex gap-1.5 mb-6 flex-wrap -mt-3">
|
||||||
|
<button
|
||||||
|
v-for="[key, label] in SUBSECCIONES_SABE"
|
||||||
|
:key="key"
|
||||||
|
class="px-3 py-1 rounded-full text-xs whitespace-nowrap transition-colors"
|
||||||
|
:class="tab === key
|
||||||
|
? 'bg-brand/15 text-brand font-medium border border-brand/40'
|
||||||
|
: 'text-tenue border border-transparent hover:text-texto'"
|
||||||
|
@click="tab = key"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</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>
|
||||||
|
<button
|
||||||
|
class="text-sm text-tenue hover:text-texto"
|
||||||
|
:disabled="!d.total_chunks"
|
||||||
|
:class="!d.total_chunks ? 'opacity-40 cursor-default' : ''"
|
||||||
|
title="Ver qué se leyó de esta fuente"
|
||||||
|
@click="verFragmentos(d)"
|
||||||
|
>Ver</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>
|
||||||
|
|
||||||
|
<!-- Ver los fragmentos de una fuente, solo lectura -->
|
||||||
|
<div
|
||||||
|
v-if="viendoDoc"
|
||||||
|
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
|
||||||
|
@click.self="viendoDoc = null"
|
||||||
|
>
|
||||||
|
<div class="card w-full max-w-2xl p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||||
|
<div class="flex items-baseline justify-between gap-3 mb-1">
|
||||||
|
<h2 class="font-semibold text-texto truncate">{{ viendoDoc.origen }}</h2>
|
||||||
|
<span class="text-xs text-tenue shrink-0 tabular-nums">
|
||||||
|
{{ fragmentos.length }} {{ fragmentos.length === 1 ? 'fragmento' : 'fragmentos' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-tenue mb-4">
|
||||||
|
Esto es exactamente lo que el asistente puede consultar de esta fuente.
|
||||||
|
Si acá falta algo o se leyó mal, eso mismo le va a faltar en las respuestas.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="cargandoFragmentos" class="text-sm text-tenue">Cargando…</p>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(f, i) in fragmentos"
|
||||||
|
:key="f.ID"
|
||||||
|
class="bg-elevado border border-borde rounded-lg p-3 text-sm text-texto whitespace-pre-wrap"
|
||||||
|
>
|
||||||
|
<span class="text-[10px] text-tenue tabular-nums block mb-1">#{{ i + 1 }}</span>{{ f.contenido }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4">
|
||||||
|
<button class="btn-ghost" @click="viendoDoc = null">Cerrar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 -->
|
||||||
|
<TabAcciones v-else-if="tab === 'acciones'" :agente-id="agenteIdNum" />
|
||||||
|
|
||||||
|
<TabAuditoria v-else :eventos="eventos" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<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'
|
||||||
|
import NavEspacio from '../components/NavEspacio.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>
|
||||||
|
<NavEspacio :tenant-id="id" />
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import { api } from '../lib/api.js'
|
||||||
|
import { apiUmind } from '../lib/contexto.js'
|
||||||
|
import NavEspacio from '../components/NavEspacio.vue'
|
||||||
|
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||||
|
|
||||||
|
const props = defineProps({ id: { type: String, required: true } })
|
||||||
|
|
||||||
|
const items = ref([])
|
||||||
|
const usado = ref(0)
|
||||||
|
const cuota = ref(0)
|
||||||
|
const cargando = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const subiendo = ref(false)
|
||||||
|
const agentes = ref([])
|
||||||
|
|
||||||
|
const porcentaje = computed(() => (cuota.value ? Math.min(100, Math.round((usado.value / cuota.value) * 100)) : 0))
|
||||||
|
|
||||||
|
function mb(bytes) {
|
||||||
|
if (!bytes) return '0 KB'
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cargar() {
|
||||||
|
cargando.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const r = await api.get(apiUmind(`/umind/archivos?tenant_id=${props.id}`))
|
||||||
|
items.value = r.items || []
|
||||||
|
usado.value = r.usado_bytes || 0
|
||||||
|
cuota.value = r.cuota_bytes || 0
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
cargando.value = false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await api.get(apiUmind(`/umind/agentes?tenant_id=${props.id}`))
|
||||||
|
agentes.value = r.items || []
|
||||||
|
} catch {
|
||||||
|
agentes.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(() => props.id, cargar, { immediate: true })
|
||||||
|
|
||||||
|
async function subir(ev) {
|
||||||
|
const file = ev.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
subiendo.value = true
|
||||||
|
error.value = ''
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('tenant_id', props.id)
|
||||||
|
fd.append('archivo', file)
|
||||||
|
try {
|
||||||
|
await api.postForm(apiUmind('/umind/archivos'), fd)
|
||||||
|
await cargar()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
subiendo.value = false
|
||||||
|
ev.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function usarComoConocimiento(a) {
|
||||||
|
if (agentes.value.length === 0) return
|
||||||
|
// Con un solo agente no se pregunta nada; con varios hay que elegir a cuál.
|
||||||
|
let agenteId = agentes.value[0].ID
|
||||||
|
if (agentes.value.length > 1) {
|
||||||
|
const opciones = agentes.value.map((g, i) => `${i + 1}. ${g.nombre}`).join('\n')
|
||||||
|
const elegido = prompt(`¿A qué asistente se lo cargamos?\n${opciones}`, '1')
|
||||||
|
if (elegido === null) return
|
||||||
|
const idx = Number(elegido) - 1
|
||||||
|
if (!agentes.value[idx]) return
|
||||||
|
agenteId = agentes.value[idx].ID
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.post(apiUmind(`/umind/archivos/${a.ID}/conocimiento`), { agente_id: agenteId })
|
||||||
|
await cargar()
|
||||||
|
alert('Listo. Lo está leyendo — en un momento va a poder responder con eso.')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eliminar(a) {
|
||||||
|
if (!confirm(`¿Eliminar ${a.nombre}?`)) return
|
||||||
|
try {
|
||||||
|
await api.del(apiUmind(`/umind/archivos/${a.ID}`))
|
||||||
|
await cargar()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mismo origen: la ruta relativa alcanza y la cookie de sesión viaja sola.
|
||||||
|
function urlDescarga(a) {
|
||||||
|
return apiUmind(`/umind/archivos/${a.ID}/descargar`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cuando(iso) {
|
||||||
|
return new Date(iso).toLocaleDateString('es', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NavEspacio :tenant-id="id" />
|
||||||
|
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-texto">Archivos</h1>
|
||||||
|
<p class="label mt-1">
|
||||||
|
Los papeles del negocio: contratos, pólizas, cotizaciones. Los documentos que genera tu
|
||||||
|
asistente también quedan acá.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="btn-primary cursor-pointer shrink-0">
|
||||||
|
{{ subiendo ? 'Subiendo…' : '+ Subir archivo' }}
|
||||||
|
<input type="file" class="hidden" :disabled="subiendo" @change="subir" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="cuota" class="mb-4">
|
||||||
|
<div class="flex justify-between text-xs text-tenue mb-1">
|
||||||
|
<span>{{ mb(usado) }} de {{ mb(cuota) }}</span>
|
||||||
|
<span>{{ porcentaje }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 rounded-full bg-elevado overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all"
|
||||||
|
:class="porcentaje > 90 ? 'bg-amber-500' : 'bg-brand'"
|
||||||
|
:style="{ width: porcentaje + '%' }"
|
||||||
|
></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="label">Cargando…</p>
|
||||||
|
|
||||||
|
<div v-else class="card divide-y divide-borde">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="items.length === 0"
|
||||||
|
titulo="Sin archivos todavía"
|
||||||
|
detalle="Subí acá los papeles del negocio. Cualquiera de ellos se le puede cargar al asistente como conocimiento con un clic."
|
||||||
|
/>
|
||||||
|
<div v-for="a in items" :key="a.ID" class="p-4 flex items-center justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="font-medium text-texto truncate">{{ a.nombre }}</span>
|
||||||
|
<span v-if="a.origen === 'generado'" class="px-1.5 py-0.5 rounded text-xs badge-ok">generado</span>
|
||||||
|
<span v-if="a.documento_id" class="px-1.5 py-0.5 rounded text-xs badge-neutro">es conocimiento</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-tenue mt-0.5">{{ mb(a.tamanio) }} · {{ cuando(a.CreatedAt) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 shrink-0 text-sm">
|
||||||
|
<a :href="urlDescarga(a)" class="text-brand hover:underline">Descargar</a>
|
||||||
|
<button
|
||||||
|
v-if="!a.documento_id && agentes.length"
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
@click="usarComoConocimiento(a)"
|
||||||
|
>Usar como conocimiento</button>
|
||||||
|
<button class="text-red-500 hover:text-red-700" @click="eliminar(a)">Eliminar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import { api } from '../lib/api.js'
|
||||||
|
import { apiUmind } from '../lib/contexto.js'
|
||||||
|
import NavEspacio from '../components/NavEspacio.vue'
|
||||||
|
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||||
|
|
||||||
|
const props = defineProps({ id: { type: String, required: true } })
|
||||||
|
|
||||||
|
const items = ref([])
|
||||||
|
const cargando = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const historial = ref(false)
|
||||||
|
const trabajando = ref(null)
|
||||||
|
|
||||||
|
const pendientes = computed(() => items.value.filter(a => a.estado === 'pendiente'))
|
||||||
|
|
||||||
|
async function cargar() {
|
||||||
|
cargando.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const r = await api.get(apiUmind(`/umind/acciones?tenant_id=${props.id}${historial.value ? '&historial=1' : ''}`))
|
||||||
|
items.value = r.items || []
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
cargando.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(() => props.id, cargar, { immediate: true })
|
||||||
|
watch(historial, cargar)
|
||||||
|
|
||||||
|
async function aprobar(a) {
|
||||||
|
trabajando.value = a.ID
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await api.post(apiUmind(`/umind/acciones/${a.ID}/aprobar`), {})
|
||||||
|
await cargar()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
trabajando.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rechazar(a) {
|
||||||
|
const motivo = prompt('¿Por qué lo rechazás? (queda para vos, el cliente no lo ve)')
|
||||||
|
if (motivo === null) return
|
||||||
|
trabajando.value = a.ID
|
||||||
|
try {
|
||||||
|
await api.post(apiUmind(`/umind/acciones/${a.ID}/rechazar`), { motivo })
|
||||||
|
await cargar()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
trabajando.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ETIQUETA_ESTADO = {
|
||||||
|
pendiente: 'esperando', aprobada: 'aprobada', ejecutada: 'hecho',
|
||||||
|
rechazada: 'rechazada', vencida: 'venció', fallida: 'falló',
|
||||||
|
}
|
||||||
|
const CLASE_ESTADO = {
|
||||||
|
ejecutada: 'badge-ok', rechazada: 'badge-neutro',
|
||||||
|
vencida: 'badge-neutro', fallida: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||||
|
}
|
||||||
|
const ICONO_TIPO = { correo: '✉️', documento: '📄', herramienta: '⚙️' }
|
||||||
|
|
||||||
|
function cuando(iso) {
|
||||||
|
return new Date(iso).toLocaleString('es', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NavEspacio :tenant-id="id" />
|
||||||
|
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-texto">Pendientes</h1>
|
||||||
|
<p class="label mt-1">
|
||||||
|
Lo que tu asistente preparó y espera tu visto bueno. Prepara solo; entregar lo decidís vos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-tenue cursor-pointer shrink-0">
|
||||||
|
<input type="checkbox" v-model="historial" class="rounded border-borde text-brand focus:ring-brand" />
|
||||||
|
Ver resueltos
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
|
||||||
|
<p v-if="cargando" class="label">Cargando…</p>
|
||||||
|
|
||||||
|
<div v-else class="card divide-y divide-borde">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="items.length === 0"
|
||||||
|
titulo="Nada esperando"
|
||||||
|
detalle="Cuando alguien le pida algo a tu asistente desde un canal público —una cotización, un correo— lo va a preparar y te lo va a dejar acá para que decidas."
|
||||||
|
/>
|
||||||
|
<div v-for="a in items" :key="a.ID" class="p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span>{{ ICONO_TIPO[a.tipo] || '⚙️' }}</span>
|
||||||
|
<span class="font-medium text-texto">{{ a.resumen }}</span>
|
||||||
|
<span
|
||||||
|
v-if="a.estado !== 'pendiente'"
|
||||||
|
class="px-1.5 py-0.5 rounded text-xs"
|
||||||
|
:class="CLASE_ESTADO[a.estado] || 'badge-neutro'"
|
||||||
|
>{{ ETIQUETA_ESTADO[a.estado] || a.estado }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-tenue mt-1">
|
||||||
|
{{ cuando(a.CreatedAt) }}
|
||||||
|
<template v-if="a.estado === 'pendiente'"> · vence el {{ cuando(a.expira_at) }}</template>
|
||||||
|
<template v-else-if="a.resuelta_por"> · {{ a.resuelta_por }}</template>
|
||||||
|
</p>
|
||||||
|
<p v-if="a.motivo" class="text-xs text-tenue mt-1 italic">Motivo: {{ a.motivo }}</p>
|
||||||
|
<router-link
|
||||||
|
v-if="a.archivo_id"
|
||||||
|
:to="`/tenants/${id}/archivos`"
|
||||||
|
class="text-xs text-brand hover:underline"
|
||||||
|
>Ver el documento</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="a.estado === 'pendiente'" class="flex items-center gap-2 shrink-0">
|
||||||
|
<button class="btn-ghost text-sm" :disabled="trabajando === a.ID" @click="rechazar(a)">Rechazar</button>
|
||||||
|
<button class="btn-primary text-sm" :disabled="trabajando === a.ID" @click="aprobar(a)">
|
||||||
|
{{ trabajando === a.ID ? 'Haciéndolo…' : 'Aprobar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { api } from '../lib/api.js'
|
||||||
|
import { apiUmind } from '../lib/contexto.js'
|
||||||
|
import NavEspacio from '../components/NavEspacio.vue'
|
||||||
|
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||||
|
|
||||||
|
const props = defineProps({ id: { type: String, required: true } })
|
||||||
|
|
||||||
|
const TIPOS = [
|
||||||
|
['cotizacion', 'Cotización'],
|
||||||
|
['contrato', 'Contrato'],
|
||||||
|
['acta', 'Acta'],
|
||||||
|
['cuenta_cobro', 'Cuenta de cobro'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const items = ref([])
|
||||||
|
const cargando = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const editando = ref(null)
|
||||||
|
const importando = ref(false)
|
||||||
|
const guardando = ref(false)
|
||||||
|
const form = ref({ tipo: 'cotizacion', nombre: '', contenido_html: '' })
|
||||||
|
|
||||||
|
async function cargar() {
|
||||||
|
cargando.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const r = await api.get(apiUmind(`/umind/plantillas?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() {
|
||||||
|
form.value = { tipo: 'cotizacion', nombre: '', contenido_html: '' }
|
||||||
|
editando.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// El camino corto: subís el contrato que ya usás y la IA lo convierte en
|
||||||
|
// plantilla con las variables puestas. No se guarda: lo revisás antes.
|
||||||
|
async function importar(ev) {
|
||||||
|
const file = ev.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
importando.value = true
|
||||||
|
error.value = ''
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('tenant_id', props.id)
|
||||||
|
fd.append('tipo', form.value.tipo)
|
||||||
|
fd.append('archivo', file)
|
||||||
|
try {
|
||||||
|
const r = await api.postForm(apiUmind('/umind/plantillas/importar'), fd)
|
||||||
|
form.value.contenido_html = r.contenido_html
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
importando.value = false
|
||||||
|
ev.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardar() {
|
||||||
|
guardando.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await api.post(apiUmind('/umind/plantillas'), { tenant_id: Number(props.id), ...form.value })
|
||||||
|
editando.value = null
|
||||||
|
await cargar()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
guardando.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eliminar(p) {
|
||||||
|
if (!confirm(`¿Eliminar la plantilla "${p.nombre}"?`)) return
|
||||||
|
try {
|
||||||
|
await api.del(apiUmind(`/umind/plantillas/${p.ID}`))
|
||||||
|
await cargar()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function etiqueta(tipo) {
|
||||||
|
return TIPOS.find(([t]) => t === tipo)?.[1] || tipo
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NavEspacio :tenant-id="id" />
|
||||||
|
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-texto">Plantillas</h1>
|
||||||
|
<p class="label mt-1">
|
||||||
|
Tus cotizaciones y contratos con tu membrete. Tu asistente los usa cuando alguien
|
||||||
|
te pide un presupuesto. Si no cargás ninguna, usa una genérica.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary shrink-0" @click="nueva">+ Nueva plantilla</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
|
||||||
|
|
||||||
|
<form v-if="editando" class="card p-4 mb-4 space-y-3" @submit.prevent="guardar">
|
||||||
|
<div class="grid sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="label">Tipo</label>
|
||||||
|
<select v-model="form.tipo" class="input">
|
||||||
|
<option v-for="[t, l] in TIPOS" :key="t" :value="t">{{ l }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Nombre</label>
|
||||||
|
<input v-model="form.nombre" placeholder="Cotización 2026" class="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-dashed border-borde p-3 text-center">
|
||||||
|
<label class="btn-ghost cursor-pointer inline-block">
|
||||||
|
{{ importando ? 'Leyéndolo…' : 'Subir el documento que ya usás' }}
|
||||||
|
<input type="file" class="hidden" accept=".docx,.pdf,.txt" :disabled="importando" @change="importar" />
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-tenue mt-2">
|
||||||
|
Subí tu contrato o cotización en Word o PDF y lo convertimos en plantilla, con los datos
|
||||||
|
variables ya marcados. Después lo revisás acá abajo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Contenido (HTML)</label>
|
||||||
|
<textarea v-model="form.contenido_html" rows="12" required class="input font-mono text-xs"></textarea>
|
||||||
|
<!-- v-pre: sin esto Vue intenta interpretar las llaves del ejemplo
|
||||||
|
como interpolación suya y no compila. -->
|
||||||
|
<p v-pre class="text-xs text-tenue mt-1">
|
||||||
|
Los datos variables van entre llaves: <span class="font-mono">{{.Cliente}}</span>,
|
||||||
|
<span class="font-mono">{{.Fecha}}</span>, <span class="font-mono">{{.Total}}</span>,
|
||||||
|
y la tabla de ítems con <span class="font-mono">{{range .Items}}</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" class="btn-ghost" @click="editando = null">Cancelar</button>
|
||||||
|
<button type="submit" class="btn-primary" :disabled="guardando">
|
||||||
|
{{ guardando ? 'Guardando…' : 'Guardar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p v-if="cargando" class="label">Cargando…</p>
|
||||||
|
<div v-else class="card divide-y divide-borde">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="items.length === 0"
|
||||||
|
titulo="Sin plantillas propias"
|
||||||
|
detalle="Tu asistente puede emitir documentos igual, con un formato genérico. Cargá el tuyo cuando quieras que salgan con tu membrete."
|
||||||
|
/>
|
||||||
|
<div v-for="p in items" :key="p.ID" class="p-4 flex items-center justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span class="font-medium text-texto">{{ p.nombre }}</span>
|
||||||
|
<span class="ml-2 text-xs text-tenue">{{ etiqueta(p.tipo) }} · versión {{ p.version }}</span>
|
||||||
|
<span v-if="p.activa" class="ml-2 px-1.5 py-0.5 rounded text-xs badge-ok">en uso</span>
|
||||||
|
</div>
|
||||||
|
<button class="text-sm text-red-500 hover:text-red-700 shrink-0" @click="eliminar(p)">Eliminar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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'
|
||||||
|
import NavEspacio from '../components/NavEspacio.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-4">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NavEspacio :tenant-id="tenantId ?? id" />
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<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'
|
||||||
|
import NavEspacio from '../components/NavEspacio.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' },
|
||||||
|
documento: { nombre: 'Documentos generados', icono: 'archivo', color: '#7b5fb0' },
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
<NavEspacio :tenant-id="id" />
|
||||||
|
|
||||||
|
<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,110 @@
|
|||||||
|
<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', correo: 'tu correo' }
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Solo conversaciones de clientes: la bandeja separa las pruebas propias, y si
|
||||||
|
// este número las incluyera diría una cosa distinta que la lista de al lado.
|
||||||
|
const reales = computed(() =>
|
||||||
|
props.sesiones.filter((s) => !String(s.session_id).startsWith('staff-preview')),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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: reales.value.length === 0,
|
||||||
|
texto: reales.value.length
|
||||||
|
? `${reales.value.length} ${reales.value.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,107 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import UiIcono from '../../components/ui/UiIcono.vue'
|
||||||
|
import UiMascota from '../../components/ui/UiMascota.vue'
|
||||||
|
|
||||||
|
// El camino de puesta en marcha, en orden. Existe porque un agente recién
|
||||||
|
// creado mostraba tres avisos ámbar sueltos ("no atiende", "no sabe nada") y
|
||||||
|
// ningún orden: el dueño sabía que faltaban cosas pero no cuál iba primero.
|
||||||
|
//
|
||||||
|
// Mientras falte algo, esta guía reemplaza al estado de arriba — una sola voz
|
||||||
|
// por etapa. Cuando los tres pasos están hechos, desaparece para siempre y el
|
||||||
|
// estado operativo toma su lugar. La puesta en marcha se hace una vez; el
|
||||||
|
// estado se mira todos los días.
|
||||||
|
const props = defineProps({
|
||||||
|
documentos: { type: Array, default: () => [] },
|
||||||
|
canales: { type: Array, default: () => [] },
|
||||||
|
sesiones: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['ir'])
|
||||||
|
|
||||||
|
const sabe = computed(() => props.documentos.some((d) => d.estado === 'listo'))
|
||||||
|
const probado = computed(() =>
|
||||||
|
props.sesiones.some((s) => String(s.session_id).startsWith('staff-preview')),
|
||||||
|
)
|
||||||
|
const atiende = computed(() => props.canales.some((c) => c.activo))
|
||||||
|
|
||||||
|
const pasos = computed(() => [
|
||||||
|
{
|
||||||
|
n: 1,
|
||||||
|
hecho: sabe.value,
|
||||||
|
titulo: 'Contale de tu negocio',
|
||||||
|
detalle: 'Escribí tus horarios, precios y lo que te preguntan siempre — o subí tu lista de precios, o dale tu sitio web.',
|
||||||
|
accion: 'Cargar información',
|
||||||
|
destino: 'conocimiento',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: 2,
|
||||||
|
hecho: probado.value,
|
||||||
|
titulo: 'Hacele una pregunta',
|
||||||
|
detalle: 'Preguntale algo como lo haría un cliente. Si contesta mal, corregí su información y probá de nuevo.',
|
||||||
|
accion: 'Probarlo',
|
||||||
|
destino: 'conocimiento',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: 3,
|
||||||
|
hecho: atiende.value,
|
||||||
|
titulo: 'Ponelo a atender',
|
||||||
|
detalle: 'Conectá tu WhatsApp o Telegram, o pegá el widget en tu web. Desde ese momento responde solo.',
|
||||||
|
accion: 'Elegir dónde',
|
||||||
|
destino: 'canales',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const hechos = computed(() => pasos.value.filter((p) => p.hecho).length)
|
||||||
|
|
||||||
|
// El siguiente paso pendiente es el único que se resalta: una guía donde todo
|
||||||
|
// grita a la vez no guía nada.
|
||||||
|
const siguiente = computed(() => pasos.value.find((p) => !p.hecho)?.n ?? null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card p-5 mb-6">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<UiMascota :estado="hechos === 0 ? 'durmiendo' : 'contenta'" :tam="52" class="text-brand shrink-0 hidden sm:block" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-baseline justify-between gap-3">
|
||||||
|
<h2 class="font-semibold text-texto">Pongamos a andar tu asistente</h2>
|
||||||
|
<span class="text-xs text-tenue tabular-nums shrink-0">{{ hechos }} de {{ pasos.length }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-tenue mt-0.5 mb-4">
|
||||||
|
Tres pasos y empieza a responderle a tus clientes. Diez minutos, sin nada técnico.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol class="space-y-2.5">
|
||||||
|
<li
|
||||||
|
v-for="p in pasos"
|
||||||
|
:key="p.n"
|
||||||
|
class="flex items-start gap-3 rounded-lg p-2.5 -m-0.5 transition-colors"
|
||||||
|
:class="p.n === siguiente ? 'bg-brand/5 border border-brand/30' : ''"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold shrink-0 mt-0.5"
|
||||||
|
:class="p.hecho ? 'bg-brand text-white' : p.n === siguiente ? 'border-2 border-brand text-brand' : 'border border-borde text-tenue'"
|
||||||
|
>
|
||||||
|
<UiIcono v-if="p.hecho" nombre="ok" :tam="13" />
|
||||||
|
<template v-else>{{ p.n }}</template>
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium" :class="p.hecho ? 'text-tenue line-through' : 'text-texto'">
|
||||||
|
{{ p.titulo }}
|
||||||
|
</p>
|
||||||
|
<p v-if="!p.hecho" class="text-xs text-tenue mt-0.5">{{ p.detalle }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!p.hecho && p.n === siguiente"
|
||||||
|
class="btn-primary !py-1.5 !px-3 text-sm shrink-0"
|
||||||
|
@click="emit('ir', p.destino)"
|
||||||
|
>
|
||||||
|
{{ p.accion }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<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'
|
||||||
|
|
||||||
|
// "Lo que hace": los recordatorios y las vigilancias que el dueño le programó
|
||||||
|
// conversando. Se crean hablando, pero se ven y se cancelan acá — un agente
|
||||||
|
// que agenda cosas invisibles es un agente en el que no se confía.
|
||||||
|
const props = defineProps({ agenteId: { type: Number, required: true } })
|
||||||
|
|
||||||
|
const avisos = ref([])
|
||||||
|
const vigilancias = ref([])
|
||||||
|
const cargando = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function cargar() {
|
||||||
|
cargando.value = true
|
||||||
|
error.value = ''
|
||||||
|
// Por separado a propósito: que falle uno no debe vaciar el otro.
|
||||||
|
try {
|
||||||
|
const r = await api.get(apiUmind(`/umind/avisos?agente_id=${props.agenteId}`))
|
||||||
|
avisos.value = (r.items || []).filter(a => a.estado === 'pendiente')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await api.get(apiUmind(`/umind/vigilancias?agente_id=${props.agenteId}`))
|
||||||
|
vigilancias.value = r.items || []
|
||||||
|
} catch {
|
||||||
|
vigilancias.value = []
|
||||||
|
}
|
||||||
|
cargando.value = false
|
||||||
|
}
|
||||||
|
watch(() => props.agenteId, cargar, { immediate: true })
|
||||||
|
|
||||||
|
async function borrarAviso(a) {
|
||||||
|
if (!confirm(`¿Cancelar el recordatorio "${a.titulo}"?`)) return
|
||||||
|
await api.del(apiUmind(`/umind/avisos/${a.ID}`))
|
||||||
|
await cargar()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function borrarVigilancia(v) {
|
||||||
|
if (!confirm(`¿Apagar la vigilancia "${v.nombre}"?`)) return
|
||||||
|
await api.del(apiUmind(`/umind/vigilancias/${v.ID}`))
|
||||||
|
await cargar()
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPETICION = { diario: 'todos los días', semanal: 'cada semana', mensual: 'cada mes', anual: 'cada año' }
|
||||||
|
|
||||||
|
function cuando(iso) {
|
||||||
|
return new Date(iso).toLocaleString('es', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function dondeLlega(destino) {
|
||||||
|
if (!destino || destino.startsWith('panel:')) return 'te llega por correo'
|
||||||
|
if (destino.startsWith('tg:')) return 'te llega por Telegram'
|
||||||
|
if (destino.startsWith('mail:')) return `te llega a ${destino.slice(5)}`
|
||||||
|
return 'te llega por correo'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||||
|
<p v-if="cargando" class="label">Cargando…</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section>
|
||||||
|
<h3 class="text-sm font-medium text-texto mb-1">Recordatorios</h3>
|
||||||
|
<p class="label mb-3">
|
||||||
|
Pediselos hablando: <em>«avisame el 15 de marzo que vence la póliza de Acme, y todos los años»</em>.
|
||||||
|
</p>
|
||||||
|
<div class="card divide-y divide-borde">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="avisos.length === 0"
|
||||||
|
titulo="Sin recordatorios"
|
||||||
|
detalle="Escribile desde tu canal privado y programá el primero. Te avisa por donde se lo pediste."
|
||||||
|
/>
|
||||||
|
<div v-for="a in avisos" :key="a.ID" class="p-4 flex items-center justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span class="font-medium text-texto">{{ a.titulo }}</span>
|
||||||
|
<p class="text-xs text-tenue mt-0.5">
|
||||||
|
{{ cuando(a.proximo_at) }}
|
||||||
|
<template v-if="a.repetir"> · se repite {{ REPETICION[a.repetir] || a.repetir }}</template>
|
||||||
|
· {{ dondeLlega(a.destino) }}
|
||||||
|
</p>
|
||||||
|
<p v-if="a.detalle" class="text-xs text-tenue mt-1">{{ a.detalle }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="text-sm text-red-500 hover:text-red-700 shrink-0" @click="borrarAviso(a)">Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 class="text-sm font-medium text-texto mb-1">Vigilancias</h3>
|
||||||
|
<p class="label mb-3">
|
||||||
|
Consultan una de tus herramientas cada tanto y te avisan cuando pasa algo.
|
||||||
|
Solo avisan al entrar en la condición, no cada vez que la revisan.
|
||||||
|
</p>
|
||||||
|
<div class="card divide-y divide-borde">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="vigilancias.length === 0"
|
||||||
|
titulo="Sin vigilancias"
|
||||||
|
detalle="Si tenés una herramienta conectada, pedile algo como «avisame cuando esta API devuelva stock en cero»."
|
||||||
|
/>
|
||||||
|
<div v-for="v in vigilancias" :key="v.ID" class="p-4 flex items-center justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="font-medium text-texto">{{ v.nombre }}</span>
|
||||||
|
<span v-if="!v.activa" class="px-1.5 py-0.5 rounded text-xs badge-neutro">apagada</span>
|
||||||
|
<span v-else-if="v.en_condicion" class="px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">se está cumpliendo</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-tenue mt-0.5">
|
||||||
|
{{ v.condicion }} · revisa cada {{ v.intervalo_min }} min · usa {{ v.herramienta }}
|
||||||
|
</p>
|
||||||
|
<p v-if="v.ultimo_error" class="text-xs text-red-600 dark:text-red-400 mt-1">{{ v.ultimo_error }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="text-sm text-red-500 hover:text-red-700 shrink-0" @click="borrarVigilancia(v)">Apagar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</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,338 @@
|
|||||||
|
<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: '',
|
||||||
|
usuario: '', password: '', imap_host: '', smtp_host: '', imap_encriptado: 'ssl', smtp_encriptado: 'starttls',
|
||||||
|
intervalo_minutos: 5, responder_a: 'todos', filtro_remitentes: '', es_interno: false,
|
||||||
|
usar_whisper_audio: false, usar_ocr_imagenes: false, usar_archivos_docs: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nuevoCanal() {
|
||||||
|
canalForm.value = canalVacio()
|
||||||
|
showCanalForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardarCanal() {
|
||||||
|
let credenciales
|
||||||
|
if (canalForm.value.tipo === 'telegram') {
|
||||||
|
credenciales = { bot_token: canalForm.value.bot_token }
|
||||||
|
} else if (canalForm.value.tipo === 'correo') {
|
||||||
|
// El host se deduce del dominio si el cliente no lo escribió: en hosting
|
||||||
|
// común mail.<dominio> acierta casi siempre.
|
||||||
|
const dominio = String(canalForm.value.usuario).split('@')[1] || ''
|
||||||
|
credenciales = {
|
||||||
|
usuario: canalForm.value.usuario,
|
||||||
|
password: canalForm.value.password,
|
||||||
|
imap_host: canalForm.value.imap_host || `mail.${dominio}`,
|
||||||
|
smtp_host: canalForm.value.smtp_host || `mail.${dominio}`,
|
||||||
|
imap_encriptado: canalForm.value.imap_encriptado,
|
||||||
|
smtp_encriptado: canalForm.value.smtp_encriptado,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
credenciales = {
|
||||||
|
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,
|
||||||
|
intervalo_minutos: Number(canalForm.value.intervalo_minutos) || 5,
|
||||||
|
filtro_remitentes: canalForm.value.responder_a === 'algunos' ? canalForm.value.filtro_remitentes : '',
|
||||||
|
es_interno: canalForm.value.es_interno,
|
||||||
|
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"></body></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 v-if="c.es_interno" class="ml-2 px-1.5 py-0.5 rounded text-xs badge-neutro">privado</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>
|
||||||
|
<!-- Un canal de correo no tiene webhook: se revisa por intervalo. -->
|
||||||
|
<p v-if="c.tipo === 'correo'" class="label mt-1">
|
||||||
|
Revisa la casilla cada {{ c.intervalo_minutos >= 60 ? '1 hora' : `${c.intervalo_minutos} minutos` }}
|
||||||
|
· {{ c.filtro_remitentes ? `responde solo a: ${c.filtro_remitentes}` : 'responde a todos' }}
|
||||||
|
</p>
|
||||||
|
<p v-else 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>
|
||||||
|
<option value="correo">Correo (tu casilla)</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-if="canalForm.tipo === 'correo'">
|
||||||
|
<p class="text-xs text-tenue">
|
||||||
|
El agente va a leer esta casilla y <strong>responder solo</strong> los correos que lleguen,
|
||||||
|
con su base de conocimiento. Ignora newsletters y correos automáticos.
|
||||||
|
</p>
|
||||||
|
<div class="grid sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="label">Correo</label>
|
||||||
|
<input v-model="canalForm.usuario" type="email" required placeholder="ventas@tunegocio.com" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Contraseña</label>
|
||||||
|
<input v-model="canalForm.password" type="password" autocomplete="new-password" required class="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Revisar la casilla cada</label>
|
||||||
|
<select v-model="canalForm.intervalo_minutos" class="input">
|
||||||
|
<option :value="2">2 minutos — respuesta casi inmediata</option>
|
||||||
|
<option :value="5">5 minutos</option>
|
||||||
|
<option :value="15">15 minutos</option>
|
||||||
|
<option :value="30">30 minutos</option>
|
||||||
|
<option :value="60">1 hora</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-tenue mt-1">Un correo no es un chat: para la mayoría, 5 o 15 minutos sobra.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Responder a</label>
|
||||||
|
<div class="flex flex-col gap-1.5 mt-1">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
|
||||||
|
<input v-model="canalForm.responder_a" type="radio" value="todos" class="text-brand focus:ring-brand" />
|
||||||
|
Todos los que escriban
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
|
||||||
|
<input v-model="canalForm.responder_a" type="radio" value="algunos" class="text-brand focus:ring-brand" />
|
||||||
|
Solo a estos remitentes
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<template v-if="canalForm.responder_a === 'algunos'">
|
||||||
|
<textarea
|
||||||
|
v-model="canalForm.filtro_remitentes"
|
||||||
|
rows="3"
|
||||||
|
required
|
||||||
|
placeholder="juan@cliente.com, @miempresa.com Direcciones completas o dominios, separados por coma."
|
||||||
|
class="input font-mono mt-2"
|
||||||
|
></textarea>
|
||||||
|
<p class="text-xs text-tenue mt-1">
|
||||||
|
Un dominio (ej. <span class="font-mono">@miempresa.com</span>) habilita a todos los de esa empresa.
|
||||||
|
El resto de los correos se ignoran sin responder.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<details class="text-xs text-tenue">
|
||||||
|
<summary class="cursor-pointer">Servidores (solo si la detección automática falla)</summary>
|
||||||
|
<div class="grid sm:grid-cols-2 gap-3 mt-2">
|
||||||
|
<div>
|
||||||
|
<label class="label">Servidor IMAP</label>
|
||||||
|
<input v-model="canalForm.imap_host" placeholder="mail.tunegocio.com" class="input font-mono" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Servidor SMTP</label>
|
||||||
|
<input v-model="canalForm.smtp_host" placeholder="mail.tunegocio.com" class="input font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</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="rounded-lg border border-borde p-3 mt-1">
|
||||||
|
<label class="flex items-start gap-2 text-sm text-texto cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="canalForm.es_interno" class="mt-0.5 rounded border-borde text-brand focus:ring-brand" />
|
||||||
|
<span>
|
||||||
|
Este canal es mío, no de mis clientes
|
||||||
|
<span class="block text-xs text-tenue mt-0.5">
|
||||||
|
Marcalo si acá te escribís solo vos. El asistente va a poder programarte
|
||||||
|
recordatorios y ejecutar acciones sin pedir permiso. En los canales donde
|
||||||
|
escriben tus clientes, todo lo que salga hacia afuera espera tu visto bueno.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -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,175 @@
|
|||||||
|
<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 },
|
||||||
|
conexiones: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['recargar'])
|
||||||
|
|
||||||
|
// Alta de una casilla IMAP. Solo correo y contraseña: el host se deduce
|
||||||
|
// (mail.<dominio>) y el campo técnico aparece recién si eso falla.
|
||||||
|
const showForm = ref(false)
|
||||||
|
const guardando = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const modoManual = ref(false)
|
||||||
|
const form = ref({ email: '', password: '', host: '', puerto: 993, encriptado: 'ssl' })
|
||||||
|
|
||||||
|
const probando = ref(null)
|
||||||
|
const resultado = ref({})
|
||||||
|
|
||||||
|
function nombreProveedor(p) {
|
||||||
|
return { google: 'Google', microsoft: 'Outlook', imap: 'Casilla propia' }[p] || p
|
||||||
|
}
|
||||||
|
|
||||||
|
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 guardarImap() {
|
||||||
|
guardando.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await api.post(apiUmind('/umind/conexiones/imap'), {
|
||||||
|
agente_id: props.agenteId,
|
||||||
|
email: form.value.email,
|
||||||
|
password: form.value.password,
|
||||||
|
host: modoManual.value ? form.value.host : '',
|
||||||
|
puerto: modoManual.value ? Number(form.value.puerto) : 0,
|
||||||
|
encriptado: form.value.encriptado,
|
||||||
|
})
|
||||||
|
showForm.value = false
|
||||||
|
form.value = { email: '', password: '', host: '', puerto: 993, encriptado: 'ssl' }
|
||||||
|
modoManual.value = false
|
||||||
|
emit('recargar')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
// Si la autodetección no encontró el servidor, se abre el modo manual:
|
||||||
|
// es el único caso donde el campo técnico se gana su lugar.
|
||||||
|
if (!modoManual.value && /conexión|conectar|resolver/i.test(e.message)) modoManual.value = true
|
||||||
|
} finally {
|
||||||
|
guardando.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probar(c) {
|
||||||
|
probando.value = c.ID
|
||||||
|
resultado.value = { ...resultado.value, [c.ID]: null }
|
||||||
|
try {
|
||||||
|
const r = await api.post(apiUmind(`/umind/conexiones/${c.ID}/probar`), {})
|
||||||
|
resultado.value = { ...resultado.value, [c.ID]: { ok: true, texto: r.message } }
|
||||||
|
} catch (e) {
|
||||||
|
resultado.value = { ...resultado.value, [c.ID]: { ok: false, texto: e.message } }
|
||||||
|
} finally {
|
||||||
|
probando.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
Las casillas que el agente puede consultar cuando se lo pedís — por ejemplo:
|
||||||
|
<em>«revisame los correos de hoy y haceme un resumen»</em>. Solo lectura: nunca marca
|
||||||
|
nada como leído ni manda nada. Podés conectar varias.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap 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>
|
||||||
|
<button class="btn-primary" @click="showForm = !showForm">
|
||||||
|
+ Casilla propia
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Casilla de hosting / cPanel / Zoho: correo y contraseña, nada más. -->
|
||||||
|
<form v-if="showForm" class="card p-4 mb-4 space-y-3" @submit.prevent="guardarImap">
|
||||||
|
<div class="grid sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="label">Correo</label>
|
||||||
|
<input v-model="form.email" type="email" required placeholder="ventas@tunegocio.com" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Contraseña</label>
|
||||||
|
<input v-model="form.password" type="password" autocomplete="new-password" required class="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="modoManual" class="grid sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="label">Servidor IMAP</label>
|
||||||
|
<input v-model="form.host" placeholder="mail.tunegocio.com" class="input font-mono" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Puerto</label>
|
||||||
|
<input v-model="form.puerto" type="number" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Encriptación</label>
|
||||||
|
<select v-model="form.encriptado" class="input">
|
||||||
|
<option value="ssl">SSL (993)</option>
|
||||||
|
<option value="starttls">STARTTLS (143)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-xs text-tenue">
|
||||||
|
Se prueba la conexión antes de guardar. Gmail y Outlook van por sus botones de arriba.
|
||||||
|
</p>
|
||||||
|
<button type="submit" class="btn-primary" :disabled="guardando">
|
||||||
|
{{ guardando ? 'Probando…' : 'Conectar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card divide-y divide-borde">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="conexiones.length === 0"
|
||||||
|
titulo="Sin cuentas conectadas"
|
||||||
|
detalle="Conectá una casilla y vas a poder pedirle al agente que te revise el correo y te lo resuma, sin abrir el webmail."
|
||||||
|
/>
|
||||||
|
<div v-for="c in conexiones" :key="c.ID" class="p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span class="font-medium text-texto">{{ c.email }}</span>
|
||||||
|
<span class="ml-2 text-xs text-tenue">{{ nombreProveedor(c.proveedor) }}</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>
|
||||||
|
<p
|
||||||
|
v-if="resultado[c.ID]"
|
||||||
|
class="text-xs mt-1"
|
||||||
|
:class="resultado[c.ID].ok ? 'text-brand' : 'text-red-600 dark:text-red-400'"
|
||||||
|
>
|
||||||
|
{{ resultado[c.ID].texto }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 shrink-0 text-sm">
|
||||||
|
<button
|
||||||
|
v-if="c.proveedor === 'imap'"
|
||||||
|
class="text-brand hover:underline disabled:opacity-40"
|
||||||
|
:disabled="probando === c.ID"
|
||||||
|
@click="probar(c)"
|
||||||
|
>{{ probando === c.ID ? 'Probando…' : 'Probar' }}</button>
|
||||||
|
<button class="text-red-500 hover:text-red-700" @click="desconectar(c)">Desconectar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<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 },
|
||||||
|
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)
|
||||||
|
const cargandoHistorial = ref(false)
|
||||||
|
|
||||||
|
// El canal se deduce del prefijo de la sesión — es como los generan los
|
||||||
|
// canales (wa:, tg:) y el chat de prueba (staff-preview-). Lo demás es el
|
||||||
|
// widget de la web.
|
||||||
|
function canalDe(sessionId) {
|
||||||
|
const id = String(sessionId || '')
|
||||||
|
if (id.startsWith('wa:')) return { nombre: 'WhatsApp', icono: 'mensaje', clase: 'badge-ok' }
|
||||||
|
if (id.startsWith('tg:')) return { nombre: 'Telegram', icono: 'mensaje', clase: 'badge-neutro' }
|
||||||
|
if (id.startsWith('staff-preview')) return { nombre: 'Prueba', icono: 'escribir', clase: 'badge-alerta' }
|
||||||
|
return { nombre: 'Tu web', icono: 'sitio', clase: 'badge-neutro' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Las pruebas propias se separan de las conversaciones reales: mezcladas,
|
||||||
|
// inflan la lista y el dueño no sabe cuáles son clientes de verdad.
|
||||||
|
const verPruebas = ref(false)
|
||||||
|
const reales = computed(() => props.sesiones.filter((s) => !String(s.session_id).startsWith('staff-preview')))
|
||||||
|
const pruebas = computed(() => props.sesiones.filter((s) => String(s.session_id).startsWith('staff-preview')))
|
||||||
|
const visibles = computed(() => (verPruebas.value ? props.sesiones : reales.value))
|
||||||
|
|
||||||
|
function cuando(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
const hoy = new Date()
|
||||||
|
const dias = Math.floor((hoy.setHours(0, 0, 0, 0) - new Date(d).setHours(0, 0, 0, 0)) / 86400000)
|
||||||
|
const hora = new Date(iso).toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
if (dias <= 0) return `hoy ${hora}`
|
||||||
|
if (dias === 1) return `ayer ${hora}`
|
||||||
|
if (dias < 7) return `hace ${dias} días`
|
||||||
|
return new Date(iso).toLocaleDateString('es', { day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verHistorial(sessionId) {
|
||||||
|
sesionActiva.value = sessionId
|
||||||
|
cargandoHistorial.value = true
|
||||||
|
try {
|
||||||
|
const r = await api.get(apiUmind(`/umind/historial?agente_id=${props.agenteId}&session_id=${sessionId}`))
|
||||||
|
historial.value = r.items || []
|
||||||
|
} finally {
|
||||||
|
cargandoHistorial.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid lg:grid-cols-[minmax(16rem,22rem)_1fr] gap-4 items-start">
|
||||||
|
<div class="card divide-y divide-borde max-h-[30rem] overflow-y-auto">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="visibles.length === 0 && pruebas.length === 0"
|
||||||
|
titulo="Nadie escribió todavía"
|
||||||
|
detalle="Cuando alguien le hable a tu asistente —por WhatsApp, Telegram o tu web— la conversación aparece acá, con lo que preguntó y lo que se le respondió."
|
||||||
|
/>
|
||||||
|
<UiEmptyState
|
||||||
|
v-else-if="visibles.length === 0"
|
||||||
|
estado="durmiendo"
|
||||||
|
titulo="Ningún cliente escribió todavía"
|
||||||
|
:detalle="`Hay ${pruebas.length} ${pruebas.length === 1 ? 'conversación de prueba tuya' : 'conversaciones de prueba tuyas'} — activá el filtro de abajo para verlas.`"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-for="s in visibles"
|
||||||
|
:key="s.session_id"
|
||||||
|
class="w-full text-left p-3 hover:bg-elevado transition-colors"
|
||||||
|
:class="sesionActiva === s.session_id ? 'bg-gray-50 dark:bg-gray-800' : ''"
|
||||||
|
@click="verHistorial(s.session_id)"
|
||||||
|
>
|
||||||
|
<!-- Lo que abrió la conversación es el dato que importa: muestra qué
|
||||||
|
le preguntan de verdad al negocio. -->
|
||||||
|
<div class="text-sm text-texto line-clamp-2">
|
||||||
|
{{ s.primer_mensaje || '(sin mensaje)' }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-1.5 text-xs text-tenue">
|
||||||
|
<span class="px-1.5 py-0.5 rounded" :class="canalDe(s.session_id).clase">
|
||||||
|
{{ canalDe(s.session_id).nombre }}
|
||||||
|
</span>
|
||||||
|
<span class="tabular-nums">{{ s.mensajes }} msj</span>
|
||||||
|
<span class="ml-auto tabular-nums">{{ cuando(s.ultimo) }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label
|
||||||
|
v-if="pruebas.length"
|
||||||
|
class="flex items-center gap-2 p-3 text-xs text-tenue cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
<input v-model="verPruebas" type="checkbox" class="rounded border-borde text-brand focus:ring-brand" />
|
||||||
|
{{ pruebas.length === 1 ? 'Mostrar mi prueba' : `Mostrar mis ${pruebas.length} pruebas` }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4 max-h-[30rem] overflow-y-auto">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="!sesionActiva"
|
||||||
|
estado="normal"
|
||||||
|
titulo="Elegí una conversación"
|
||||||
|
detalle="Vas a ver el intercambio completo: lo que escribió la persona y lo que contestó tu asistente. Si una respuesta no te gusta, corregí su información en «Lo que sabe»."
|
||||||
|
/>
|
||||||
|
<p v-else-if="cargandoHistorial" class="text-sm text-tenue">Cargando…</p>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div v-for="m in historial" :key="m.ID" :class="m.role === 'user' ? 'text-right' : ''">
|
||||||
|
<div
|
||||||
|
class="inline-block max-w-[80%] px-3 py-2 rounded-lg text-sm text-left whitespace-pre-wrap"
|
||||||
|
:class="m.role === 'user' ? 'bg-brand text-white' : 'bg-elevado text-texto'"
|
||||||
|
>
|
||||||
|
{{ m.content }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] text-tenue mt-0.5 tabular-nums" :class="m.role === 'user' ? 'pr-1' : 'pl-1'">
|
||||||
|
{{ cuando(m.CreatedAt) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
@@ -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: [],
|
||||||
|
}
|
||||||
@@ -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
@@ -10,8 +10,7 @@
|
|||||||
"watch-poll": "mix watch -- --watch-options-poll=1000",
|
"watch-poll": "mix watch -- --watch-options-poll=1000",
|
||||||
"hot": "mix watch --hot",
|
"hot": "mix watch --hot",
|
||||||
"production": "mix --production",
|
"production": "mix --production",
|
||||||
"prod": "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 &"
|
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
+192
-22
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/utils"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ type AiConfig struct {
|
|||||||
ApiKey string `gorm:"type:text;not null" json:"api_key"` // Clave de API
|
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
|
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
|
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"`
|
Notes string `gorm:"type:text" json:"notes"`
|
||||||
// Modulo indica a qué servicio pertenece esta config.
|
// Modulo indica a qué servicio pertenece esta config.
|
||||||
// "" = global (disponible para todos como fallback)
|
// "" = global (disponible para todos como fallback)
|
||||||
@@ -29,10 +30,52 @@ type AiConfig struct {
|
|||||||
// Solo debe haber una config activa como agente a la vez.
|
// Solo debe haber una config activa como agente a la vez.
|
||||||
EsAgenteBot bool `gorm:"default:false" json:"es_agente_bot"`
|
EsAgenteBot bool `gorm:"default:false" json:"es_agente_bot"`
|
||||||
TelegramConfigID *uint `gorm:"index" json:"telegram_config_id"`
|
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" }
|
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) {
|
func GetAllAiConfigs(limit, offset int, search string) ([]AiConfig, int64, error) {
|
||||||
var items []AiConfig
|
var items []AiConfig
|
||||||
var total int64
|
var total int64
|
||||||
@@ -65,21 +108,6 @@ func GetAiConfigByID(id uint, out *AiConfig) error {
|
|||||||
return app.Http.Database.DB.First(out, id).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.
|
// SplitModulos parte el campo Modulo (comma-separated) en un slice limpio.
|
||||||
// "" → [] (config global), "landing,query_runner" → ["landing","query_runner"]
|
// "" → [] (config global), "landing,query_runner" → ["landing","query_runner"]
|
||||||
func SplitModulos(modulo string) []string {
|
func SplitModulos(modulo string) []string {
|
||||||
@@ -111,10 +139,57 @@ func GetAiConfigSelect() ([]AiConfig, error) {
|
|||||||
return items, nil
|
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.
|
// GetAgenteBotConfig retorna la config marcada como agente Telegram, con su TelegramConfig cargada.
|
||||||
func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
|
func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
|
||||||
var ai AiConfig
|
var ai AiConfig
|
||||||
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil {
|
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)
|
return nil, nil, fmt.Errorf("no hay agente bot configurado: %w", err)
|
||||||
}
|
}
|
||||||
if ai.TelegramConfigID == nil {
|
if ai.TelegramConfigID == nil {
|
||||||
@@ -132,7 +207,7 @@ func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
|
|||||||
// asignado. La usa el chat propio del dashboard para compartir el mismo motor.
|
// asignado. La usa el chat propio del dashboard para compartir el mismo motor.
|
||||||
func GetAgenteBotAiConfig() (*AiConfig, error) {
|
func GetAgenteBotAiConfig() (*AiConfig, error) {
|
||||||
var ai AiConfig
|
var ai AiConfig
|
||||||
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil {
|
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 nil, fmt.Errorf("no hay agente configurado: %w", err)
|
||||||
}
|
}
|
||||||
return &ai, nil
|
return &ai, nil
|
||||||
@@ -145,7 +220,15 @@ func GetAgenteBotAiConfig() (*AiConfig, error) {
|
|||||||
// 3. Cualquier config activa (último recurso)
|
// 3. Cualquier config activa (último recurso)
|
||||||
func GetAiConfigForService(service string) (*AiConfig, error) {
|
func GetAiConfigForService(service string) (*AiConfig, error) {
|
||||||
var items []AiConfig
|
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)
|
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
|
||||||
}
|
}
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
@@ -170,7 +253,94 @@ func GetAiConfigForService(service string) (*AiConfig, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Cualquier config activa como último recurso
|
// 3. Cualquier config activa como último recurso, pero nunca una que esté
|
||||||
log.Printf("[AI_CONFIG] No se encontró config para servicio '%s', usando cualquier activa", service)
|
// dedicada a un servicio que no sabe conversar: la de embeddings devuelve
|
||||||
return &items[0], nil
|
// 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)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
type ClienteDocumento struct {
|
type ClienteDocumento struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;not null;index"`
|
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;not null;index"`
|
||||||
Nombre string `json:"nombre" gorm:"column:nombre"` // nombre descriptivo del doc
|
Nombre string `json:"nombre" gorm:"column:nombre"` // nombre descriptivo del doc
|
||||||
Archivo string `json:"archivo" gorm:"column:archivo"` // ruta relativa en disco
|
Archivo string `json:"archivo" gorm:"column:archivo"` // ruta relativa en disco
|
||||||
OriginalName string `json:"original_name" gorm:"column:original_name"` // nombre original del archivo
|
OriginalName string `json:"original_name" gorm:"column:original_name"` // nombre original del archivo
|
||||||
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime"`
|
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime"`
|
||||||
Tamanio int64 `json:"tamanio" gorm:"column:tamanio"` // bytes
|
Tamanio int64 `json:"tamanio" gorm:"column:tamanio"` // bytes
|
||||||
FechaExpedicion *time.Time `json:"fecha_expedicion" gorm:"column:fecha_expedicion"` // opcional
|
FechaExpedicion *time.Time `json:"fecha_expedicion" gorm:"column:fecha_expedicion"` // opcional
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import (
|
|||||||
type CloudflareConfig struct {
|
type CloudflareConfig struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
// Tipo de autenticación: "token" (defecto) o "global_key"
|
// Tipo de autenticación: "token" (defecto) o "global_key"
|
||||||
AuthType string `json:"auth_type" gorm:"column:auth_type;type:varchar(20);default:'token'"`
|
AuthType string `json:"auth_type" gorm:"column:auth_type;type:varchar(20);default:'token'"`
|
||||||
// API Token (AuthType=token) o Global API Key (AuthType=global_key)
|
// API Token (AuthType=token) o Global API Key (AuthType=global_key)
|
||||||
APIToken string `json:"api_token" gorm:"column:api_token;type:text;not null"`
|
APIToken string `json:"api_token" gorm:"column:api_token;type:text;not null"`
|
||||||
// Email de la cuenta (requerido solo para AuthType=global_key)
|
// Email de la cuenta (requerido solo para AuthType=global_key)
|
||||||
Email string `json:"email" gorm:"column:email;type:text"`
|
Email string `json:"email" gorm:"column:email;type:text"`
|
||||||
// Account ID principal (opcional, para endpoints de cuenta)
|
// Account ID principal (opcional, para endpoints de cuenta)
|
||||||
AccountID string `json:"account_id" gorm:"column:account_id;type:text"`
|
AccountID string `json:"account_id" gorm:"column:account_id;type:text"`
|
||||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package models
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
@@ -93,6 +94,11 @@ type CuentaPagar struct {
|
|||||||
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
|
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
|
||||||
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
|
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
|
||||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
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" }
|
func (CuentaPagar) TableName() string { return "contab_cuentas_pagar" }
|
||||||
@@ -116,7 +122,9 @@ func (ConsolidadoMensual) TableName() string { return "contab_consolidado" }
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
func GetAllCuentas(limit, offset int, search string) ([]Cuenta, int64, error) {
|
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
|
var total int64
|
||||||
db := app.Http.Database.DB.Model(&Cuenta{})
|
db := app.Http.Database.DB.Model(&Cuenta{})
|
||||||
if search != "" {
|
if search != "" {
|
||||||
@@ -132,7 +140,9 @@ func GetAllCuentas(limit, offset int, search string) ([]Cuenta, int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetAllCuentasSelect() ([]Cuenta, 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 {
|
if err := app.Http.Database.DB.Where("activo = ?", true).Order("nombre ASC").Find(&items).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -162,7 +172,9 @@ func DeleteCuenta(id uint) error {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
func GetAllEntidades(limit, offset int, search string) ([]Entidad, int64, 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
|
var total int64
|
||||||
db := app.Http.Database.DB.Model(&Entidad{})
|
db := app.Http.Database.DB.Model(&Entidad{})
|
||||||
if search != "" {
|
if search != "" {
|
||||||
@@ -179,7 +191,9 @@ func GetAllEntidades(limit, offset int, search string) ([]Entidad, int64, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetAllEntidadesSelect() ([]Entidad, 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 {
|
if err := app.Http.Database.DB.Where("activo = ?", true).Order("nombre ASC").Find(&items).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -190,6 +204,26 @@ func CreateEntidad(e *Entidad) error {
|
|||||||
return app.Http.Database.DB.Create(e).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 {
|
func UpdateEntidad(e *Entidad) error {
|
||||||
return app.Http.Database.DB.Model(&Entidad{}).Where("id = ?", e.ID).Updates(map[string]interface{}{
|
return app.Http.Database.DB.Model(&Entidad{}).Where("id = ?", e.ID).Updates(map[string]interface{}{
|
||||||
"nombre": e.Nombre,
|
"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) {
|
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
|
var total int64
|
||||||
db := app.Http.Database.DB.Model(&Transaccion{}).Preload("Cuenta").Preload("Entidad")
|
db := app.Http.Database.DB.Model(&Transaccion{}).Preload("Cuenta").Preload("Entidad")
|
||||||
if search != "" {
|
if search != "" {
|
||||||
@@ -300,7 +336,9 @@ func recomputarConsolidadoDeFecha(fecha time.Time) {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
func GetAllCuentasCobro(limit, offset int, search string, estado string) ([]CuentaCobro, int64, 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
|
var total int64
|
||||||
db := app.Http.Database.DB.Model(&CuentaCobro{}).Preload("Cliente").Preload("Entidad").Preload("Transaccion")
|
db := app.Http.Database.DB.Model(&CuentaCobro{}).Preload("Cliente").Preload("Entidad").Preload("Transaccion")
|
||||||
if search != "" {
|
if search != "" {
|
||||||
@@ -582,8 +620,18 @@ func DeleteCuentaCobro(id uint) error {
|
|||||||
// ─── CRUD: CuentaPagar ──────────────────────────────────────────────────────
|
// ─── 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) {
|
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
|
var total int64
|
||||||
db := app.Http.Database.DB.Model(&CuentaPagar{}).Preload("Entidad").Preload("Transaccion")
|
db := app.Http.Database.DB.Model(&CuentaPagar{}).Preload("Entidad").Preload("Transaccion")
|
||||||
if search != "" {
|
if search != "" {
|
||||||
@@ -716,7 +764,9 @@ func CalcularYGuardarConsolidado(mes, anio int) (*ConsolidadoMensual, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ListConsolidados(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")
|
db := app.Http.Database.DB.Model(&ConsolidadoMensual{}).Order("anio DESC, mes DESC")
|
||||||
if anio > 0 {
|
if anio > 0 {
|
||||||
db = db.Where("anio = ?", anio)
|
db = db.Where("anio = ?", anio)
|
||||||
|
|||||||
+34
-1
@@ -236,14 +236,42 @@ func DeleteContrato(id uint) error {
|
|||||||
return db.Delete(&c).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 {
|
func GuardarEnlacePago(contratoID uint, linkID, url string) error {
|
||||||
return app.Http.Database.DB.Model(&Contrato{}).Where("id = ?", contratoID).Updates(map[string]interface{}{
|
return app.Http.Database.DB.Model(&Contrato{}).Where("id = ?", contratoID).Updates(map[string]interface{}{
|
||||||
"enlace_pago": url,
|
"enlace_pago": url,
|
||||||
"enlace_pago_link_id": linkID,
|
"enlace_pago_link_id": linkID,
|
||||||
|
"pago_confirmado": false,
|
||||||
|
"fecha_pago": nil,
|
||||||
}).Error
|
}).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,
|
// LimpiarEnlacePago borra el enlace vigente cuando se registra un pago aprobado,
|
||||||
// para que la próxima notificación genere un link nuevo.
|
// para que la próxima notificación genere un link nuevo.
|
||||||
func LimpiarEnlacePago(contratoID uint) error {
|
func LimpiarEnlacePago(contratoID uint) error {
|
||||||
@@ -333,6 +361,11 @@ func MarcarContratoPagado(contratoID uint) (bool, error) {
|
|||||||
result := app.Http.Database.DB.Model(&Contrato{}).
|
result := app.Http.Database.DB.Model(&Contrato{}).
|
||||||
Where("id = ? AND pago_confirmado = false", contratoID).
|
Where("id = ? AND pago_confirmado = false", contratoID).
|
||||||
Updates(updates)
|
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
|
return result.RowsAffected > 0, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
type ConxDb struct {
|
type ConxDb struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
Nombre string `json:"nombre" gorm:"column:nombre"`
|
Nombre string `json:"nombre" gorm:"column:nombre"`
|
||||||
Host string `json:"host" gorm:"column:host"` // vacío = usa Servidor.IpServidor
|
Host string `json:"host" gorm:"column:host"` // vacío = usa Servidor.IpServidor
|
||||||
Puerto string `json:"puerto" gorm:"column:puerto"`
|
Puerto string `json:"puerto" gorm:"column:puerto"`
|
||||||
Usuario string `json:"usuario" gorm:"column:usuario"`
|
Usuario string `json:"usuario" gorm:"column:usuario"`
|
||||||
Password string `json:"password" gorm:"column:password"`
|
Password string `json:"password" gorm:"column:password"`
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type DocPagina struct {
|
|||||||
Slug string `json:"slug" gorm:"column:slug;not null;uniqueIndex:idx_saas_slug"`
|
Slug string `json:"slug" gorm:"column:slug;not null;uniqueIndex:idx_saas_slug"`
|
||||||
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
||||||
TipoContenido string `json:"tipo_contenido" gorm:"column:tipo_contenido;default:'markdown'"` // markdown | html
|
TipoContenido string `json:"tipo_contenido" gorm:"column:tipo_contenido;default:'markdown'"` // markdown | html
|
||||||
Visibilidad string `json:"visibilidad" gorm:"column:visibilidad;default:'public'"` // public | private
|
Visibilidad string `json:"visibilidad" gorm:"column:visibilidad;default:'public'"` // public | private
|
||||||
Orden int `json:"orden" gorm:"column:orden;default:0"`
|
Orden int `json:"orden" gorm:"column:orden;default:0"`
|
||||||
Publicado bool `json:"publicado" gorm:"column:publicado;default:false"`
|
Publicado bool `json:"publicado" gorm:"column:publicado;default:false"`
|
||||||
CreadoPor uint `json:"creado_por" gorm:"column:creado_por"`
|
CreadoPor uint `json:"creado_por" gorm:"column:creado_por"`
|
||||||
|
|||||||
@@ -12,32 +12,32 @@ import (
|
|||||||
type LandingSession struct {
|
type LandingSession struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
Token string `json:"token" gorm:"column:token;uniqueIndex;not null"`
|
Token string `json:"token" gorm:"column:token;uniqueIndex;not null"`
|
||||||
UserName string `json:"user_name" gorm:"column:user_name"`
|
UserName string `json:"user_name" gorm:"column:user_name"`
|
||||||
UserEmail string `json:"user_email" gorm:"column:user_email"`
|
UserEmail string `json:"user_email" gorm:"column:user_email"`
|
||||||
UserPhone string `json:"user_phone" gorm:"column:user_phone"`
|
UserPhone string `json:"user_phone" gorm:"column:user_phone"`
|
||||||
UserCompany string `json:"user_company" gorm:"column:user_company"`
|
UserCompany string `json:"user_company" gorm:"column:user_company"`
|
||||||
UserJob string `json:"user_job" gorm:"column:user_job"`
|
UserJob string `json:"user_job" gorm:"column:user_job"`
|
||||||
UserWebsite string `json:"user_website" gorm:"column:user_website"`
|
UserWebsite string `json:"user_website" gorm:"column:user_website"`
|
||||||
UserAddress string `json:"user_address" gorm:"column:user_address"`
|
UserAddress string `json:"user_address" gorm:"column:user_address"`
|
||||||
SocialMedia string `json:"social_media" gorm:"column:social_media"`
|
SocialMedia string `json:"social_media" gorm:"column:social_media"`
|
||||||
LogoUrl string `json:"logo_url" gorm:"column:logo_url"`
|
LogoUrl string `json:"logo_url" gorm:"column:logo_url"`
|
||||||
// Historial de mensajes almacenado como JSON
|
// Historial de mensajes almacenado como JSON
|
||||||
AnswersJSON string `json:"-" gorm:"column:answers_json;type:text"`
|
AnswersJSON string `json:"-" gorm:"column:answers_json;type:text"`
|
||||||
HTMLContent string `json:"html_content,omitempty" gorm:"column:html_content;type:text"`
|
HTMLContent string `json:"html_content,omitempty" gorm:"column:html_content;type:text"`
|
||||||
// status: in_progress | generated | paid | expired
|
// status: in_progress | generated | paid | expired
|
||||||
Status string `json:"status" gorm:"column:status;default:'in_progress'"`
|
Status string `json:"status" gorm:"column:status;default:'in_progress'"`
|
||||||
BoldLinkID string `json:"bold_link_id" gorm:"column:bold_link_id"`
|
BoldLinkID string `json:"bold_link_id" gorm:"column:bold_link_id"`
|
||||||
BoldPaid bool `json:"bold_paid" gorm:"column:bold_paid;default:false"`
|
BoldPaid bool `json:"bold_paid" gorm:"column:bold_paid;default:false"`
|
||||||
BoldPaymentID string `json:"bold_payment_id" gorm:"column:bold_payment_id"`
|
BoldPaymentID string `json:"bold_payment_id" gorm:"column:bold_payment_id"`
|
||||||
PriceCOP int64 `json:"price_cop" gorm:"column:price_cop;default:49900"`
|
PriceCOP int64 `json:"price_cop" gorm:"column:price_cop;default:49900"`
|
||||||
VcardIncluded bool `json:"vcard_included" gorm:"column:vcard_included;default:false"`
|
VcardIncluded bool `json:"vcard_included" gorm:"column:vcard_included;default:false"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LandingSession) TableName() string { return "landing_sessions" }
|
func (LandingSession) TableName() string { return "landing_sessions" }
|
||||||
|
|
||||||
// LandingMessage es un mensaje del chat (serializado en AnswersJSON).
|
// LandingMessage es un mensaje del chat (serializado en AnswersJSON).
|
||||||
type LandingMessage struct {
|
type LandingMessage struct {
|
||||||
Role string `json:"role"` // "assistant" | "user"
|
Role string `json:"role"` // "assistant" | "user"
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-10
@@ -11,11 +11,12 @@ import (
|
|||||||
// Define qué canales activar para cada combinación evento × destinatario.
|
// Define qué canales activar para cada combinación evento × destinatario.
|
||||||
//
|
//
|
||||||
// Eventos disponibles (extensibles):
|
// Eventos disponibles (extensibles):
|
||||||
// ticket_nuevo → admin recibe cuando el cliente abre un ticket
|
//
|
||||||
// ticket_respuesta_cliente → admin recibe cuando el cliente responde
|
// ticket_nuevo → admin recibe cuando el cliente abre un ticket
|
||||||
// ticket_respuesta_admin → portal_user recibe cuando admin responde
|
// ticket_respuesta_cliente → admin recibe cuando el cliente responde
|
||||||
// factura_emitida → portal_user recibe cuando se emite factura
|
// ticket_respuesta_admin → portal_user recibe cuando admin responde
|
||||||
// avance_publicado → portal_user recibe cuando se publica avance
|
// factura_emitida → portal_user recibe cuando se emite factura
|
||||||
|
// avance_publicado → portal_user recibe cuando se publica avance
|
||||||
//
|
//
|
||||||
// Destinatarios: "admin" | "portal_user"
|
// Destinatarios: "admin" | "portal_user"
|
||||||
type NotifEventoConfig struct {
|
type NotifEventoConfig struct {
|
||||||
@@ -168,11 +169,11 @@ func SaveServidorAlertaUmbral(cfg *ServidorAlertaUmbral) error {
|
|||||||
return db.Create(cfg).Error
|
return db.Create(cfg).Error
|
||||||
}
|
}
|
||||||
return db.Model(&existing).Updates(map[string]interface{}{
|
return db.Model(&existing).Updates(map[string]interface{}{
|
||||||
"activo": cfg.Activo,
|
"activo": cfg.Activo,
|
||||||
"minutos_sin_ping": cfg.MinutosSinPing,
|
"minutos_sin_ping": cfg.MinutosSinPing,
|
||||||
"umbral_cpu": cfg.UmbralCPU,
|
"umbral_cpu": cfg.UmbralCPU,
|
||||||
"umbral_ram": cfg.UmbralRAM,
|
"umbral_ram": cfg.UmbralRAM,
|
||||||
"umbral_disco": cfg.UmbralDisco,
|
"umbral_disco": cfg.UmbralDisco,
|
||||||
"dias_ante_vencimiento": cfg.DiasAnteVencimiento,
|
"dias_ante_vencimiento": cfg.DiasAnteVencimiento,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ type OssApi struct {
|
|||||||
BucketName string `gorm:"not null" json:"bucket_name"` // Nombre del bucket o access point
|
BucketName string `gorm:"not null" json:"bucket_name"` // Nombre del bucket o access point
|
||||||
Region string `gorm:"size:50" json:"region"` // Región, opcional
|
Region string `gorm:"size:50" json:"region"` // Región, opcional
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"` // Activar o desactivar config
|
IsActive bool `gorm:"default:true" json:"is_active"` // Activar o desactivar config
|
||||||
PublicURL string `gorm:"size:255" json:"public_url"` // URL pública para S3 (opcional)
|
PublicURL string `gorm:"size:255" json:"public_url"` // URL pública para S3 (opcional)
|
||||||
Notes string `gorm:"type:text" json:"notes"`
|
Notes string `gorm:"type:text" json:"notes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ func GetAllOssApi(limit, offset int, search, provider, estado, sortBy, sortDir,
|
|||||||
sortDir = "desc"
|
sortDir = "desc"
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Order(sortBy+" "+sortDir).Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
if err := db.Order(sortBy + " " + sortDir).Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||||
log.Printf("Error retrieving OssApi: %v", err)
|
log.Printf("Error retrieving OssApi: %v", err)
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -26,11 +26,11 @@ func (PartnerRecurso) TableName() string { return "partner_recursos" }
|
|||||||
|
|
||||||
type PartnerComunicado struct {
|
type PartnerComunicado struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
Titulo string `json:"titulo" gorm:"column:titulo"`
|
Titulo string `json:"titulo" gorm:"column:titulo"`
|
||||||
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
||||||
Archivo string `json:"archivo" gorm:"column:archivo"`
|
Archivo string `json:"archivo" gorm:"column:archivo"`
|
||||||
NombreOrig string `json:"nombre_orig" gorm:"column:nombre_orig"`
|
NombreOrig string `json:"nombre_orig" gorm:"column:nombre_orig"`
|
||||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (PartnerComunicado) TableName() string { return "partner_comunicados" }
|
func (PartnerComunicado) TableName() string { return "partner_comunicados" }
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ type PlantillaDocumento struct {
|
|||||||
Version int `json:"version" gorm:"column:version;default:1"`
|
Version int `json:"version" gorm:"column:version;default:1"`
|
||||||
Activa bool `json:"activa" gorm:"column:activa;default:true"`
|
Activa bool `json:"activa" gorm:"column:activa;default:true"`
|
||||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||||
|
// TenantID separa las plantillas de cada cliente de las globales del
|
||||||
|
// staff. Nulo = global (el comportamiento de siempre). Mismo patrón que
|
||||||
|
// AiConfig — el que ya tiene su lección aprendida y su test que la cuida.
|
||||||
|
TenantID *uint `json:"tenant_id" gorm:"column:tenant_id;index"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (PlantillaDocumento) TableName() string { return "plantillas_documento" }
|
func (PlantillaDocumento) TableName() string { return "plantillas_documento" }
|
||||||
@@ -50,10 +54,13 @@ func GetPlantillaDocumentoByID(id uint) (*PlantillaDocumento, error) {
|
|||||||
|
|
||||||
// GetPlantillaDocumentoActiva retorna la plantilla activa más reciente para un tipo dado.
|
// GetPlantillaDocumentoActiva retorna la plantilla activa más reciente para un tipo dado.
|
||||||
// Es la que usan los endpoints de generación (cotizaciones, contratos, etc).
|
// Es la que usan los endpoints de generación (cotizaciones, contratos, etc).
|
||||||
|
// GetPlantillaDocumentoActiva devuelve la plantilla GLOBAL del staff. Filtra
|
||||||
|
// tenant_id IS NULL a propósito: sin eso, la plantilla que un cliente escribe
|
||||||
|
// para su propio contrato podría salir en un documento de la empresa.
|
||||||
func GetPlantillaDocumentoActiva(tipo string) (*PlantillaDocumento, error) {
|
func GetPlantillaDocumentoActiva(tipo string) (*PlantillaDocumento, error) {
|
||||||
var item PlantillaDocumento
|
var item PlantillaDocumento
|
||||||
if err := app.Http.Database.DB.
|
if err := app.Http.Database.DB.
|
||||||
Where("tipo = ? AND activa = ?", tipo, true).
|
Where("tipo = ? AND activa = ? AND tenant_id IS NULL", tipo, true).
|
||||||
Order("version DESC").
|
Order("version DESC").
|
||||||
First(&item).Error; err != nil {
|
First(&item).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -61,6 +68,28 @@ func GetPlantillaDocumentoActiva(tipo string) (*PlantillaDocumento, error) {
|
|||||||
return &item, nil
|
return &item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPlantillaDocumentoDeTenant busca la plantilla propia del espacio y, si no
|
||||||
|
// tiene una para ese tipo, cae a la global. Así un cliente nuevo puede emitir
|
||||||
|
// una cotización desde el primer día y personalizarla cuando quiera.
|
||||||
|
func GetPlantillaDocumentoDeTenant(tenantID uint, tipo string) (*PlantillaDocumento, error) {
|
||||||
|
var item PlantillaDocumento
|
||||||
|
err := app.Http.Database.DB.
|
||||||
|
Where("tipo = ? AND activa = ? AND tenant_id = ?", tipo, true, tenantID).
|
||||||
|
Order("version DESC").First(&item).Error
|
||||||
|
if err == nil {
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
return GetPlantillaDocumentoActiva(tipo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlantillasDeTenant lista las propias del espacio, sin las globales.
|
||||||
|
func GetPlantillasDeTenant(tenantID uint) ([]PlantillaDocumento, error) {
|
||||||
|
var items []PlantillaDocumento
|
||||||
|
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).
|
||||||
|
Order("tipo ASC, version DESC").Limit(100).Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
func CreatePlantillaDocumento(p PlantillaDocumento) error {
|
func CreatePlantillaDocumento(p PlantillaDocumento) error {
|
||||||
return app.Http.Database.DB.Create(&p).Error
|
return app.Http.Database.DB.Create(&p).Error
|
||||||
}
|
}
|
||||||
@@ -72,3 +101,29 @@ func UpdatePlantillaDocumento(id uint, updates map[string]interface{}) error {
|
|||||||
func DeletePlantillaDocumento(id uint) error {
|
func DeletePlantillaDocumento(id uint) error {
|
||||||
return app.Http.Database.DB.Delete(&PlantillaDocumento{}, id).Error
|
return app.Http.Database.DB.Delete(&PlantillaDocumento{}, id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TienePlantillasDocumento dice si el agente puede emitir documentos. Sin
|
||||||
|
// plantilla la tool no se le ofrece al modelo: prometerle una capacidad que
|
||||||
|
// después falla es peor que no tenerla.
|
||||||
|
func TienePlantillasDocumento(agenteID uint) bool {
|
||||||
|
agente, err := GetUmindAgenteByID(agenteID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
app.Http.Database.DB.Model(&PlantillaDocumento{}).
|
||||||
|
Where("activa = ? AND deleted_at IS NULL AND (tenant_id = ? OR tenant_id IS NULL)", true, agente.TenantID).
|
||||||
|
Count(&n)
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiguienteVersionPlantilla numera la nueva versión del cliente. Se guarda
|
||||||
|
// versión nueva en vez de pisar la vieja: si la nueva sale mal, la anterior
|
||||||
|
// sigue ahí.
|
||||||
|
func SiguienteVersionPlantilla(tenantID uint, tipo string) int {
|
||||||
|
var max int
|
||||||
|
app.Http.Database.DB.Model(&PlantillaDocumento{}).
|
||||||
|
Where("tenant_id = ? AND tipo = ?", tenantID, tipo).
|
||||||
|
Select("COALESCE(MAX(version), 0)").Scan(&max)
|
||||||
|
return max + 1
|
||||||
|
}
|
||||||
|
|||||||
+26
-11
@@ -181,12 +181,27 @@ func RemovePortalAcceso(portalUserID, clienteID uint) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetClienteIDsForPortalUser devuelve todos los clienteIDs accesibles para un portal user.
|
// GetClienteIDsForPortalUser devuelve todos los clienteIDs accesibles para un portal user.
|
||||||
func GetClienteIDsForPortalUser(u *PortalUser) []uint {
|
// EsPartner es la ÚNICA definición de "este usuario es partner". Existe
|
||||||
isPartner := u.Rol == "partner"
|
// porque el rol se guarda en dos lados (la columna Rol y el flag
|
||||||
if u.Role != nil {
|
// EsPortalPartner del Role) y consultarlos por separado se contradice: el
|
||||||
isPartner = u.Role.EsPortalPartner
|
// 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))
|
ids := make([]uint, 0, len(u.PortalAccesos))
|
||||||
for _, a := range u.PortalAccesos {
|
for _, a := range u.PortalAccesos {
|
||||||
ids = append(ids, a.ClienteID)
|
ids = append(ids, a.ClienteID)
|
||||||
@@ -202,12 +217,12 @@ func GetClienteIDsForPortalUser(u *PortalUser) []uint {
|
|||||||
// ─── PortalPasswordResetToken ────────────────────────────────────────────────
|
// ─── PortalPasswordResetToken ────────────────────────────────────────────────
|
||||||
|
|
||||||
type PortalPasswordResetToken struct {
|
type PortalPasswordResetToken struct {
|
||||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
PortalUserID uint `gorm:"column:portal_user_id;index;not null"`
|
PortalUserID uint `gorm:"column:portal_user_id;index;not null"`
|
||||||
Token string `gorm:"column:token;uniqueIndex;not null"`
|
Token string `gorm:"column:token;uniqueIndex;not null"`
|
||||||
ExpiresAt time.Time `gorm:"column:expires_at;not null"`
|
ExpiresAt time.Time `gorm:"column:expires_at;not null"`
|
||||||
Used bool `gorm:"column:used;default:false"`
|
Used bool `gorm:"column:used;default:false"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (PortalPasswordResetToken) TableName() string { return "portal_password_reset_tokens" }
|
func (PortalPasswordResetToken) TableName() string { return "portal_password_reset_tokens" }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -327,3 +327,53 @@ func GetProyectoDocumentoByID(id uint) (*ProyectoDocumento, error) {
|
|||||||
func DeleteProyectoDocumento(id uint) error {
|
func DeleteProyectoDocumento(id uint) error {
|
||||||
return app.Http.Database.DB.Delete(&ProyectoDocumento{}, id).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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,19 +9,26 @@ import (
|
|||||||
|
|
||||||
type ProyectoTicket struct {
|
type ProyectoTicket struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
||||||
PortalUserID *uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
|
PortalUserID *uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
|
||||||
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
|
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
|
||||||
EmailFrom string `json:"email_from" gorm:"column:email_from;size:255"`
|
EmailFrom string `json:"email_from" gorm:"column:email_from;size:255"`
|
||||||
Titulo string `json:"titulo" gorm:"column:titulo"`
|
Titulo string `json:"titulo" gorm:"column:titulo"`
|
||||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||||
Estado string `json:"estado" gorm:"column:estado;default:'abierto'"` // abierto|en_progreso|resuelto|cerrado
|
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
|
Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"` // baja|media|alta|urgente
|
||||||
Origen string `json:"origen" gorm:"column:origen;default:'portal'"` // portal|email
|
Origen string `json:"origen" gorm:"column:origen;default:'portal'"` // portal|email
|
||||||
AsignadoA *uint `json:"asignado_a" gorm:"column:asignado_a;index"`
|
AsignadoA *uint `json:"asignado_a" gorm:"column:asignado_a;index"`
|
||||||
Asignado *Users `json:"asignado" gorm:"foreignKey:AsignadoA"`
|
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)
|
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo que originó el ticket (dedup)
|
||||||
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
|
// 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" }
|
func (ProyectoTicket) TableName() string { return "proyecto_tickets" }
|
||||||
@@ -105,7 +112,7 @@ func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) {
|
|||||||
|
|
||||||
func GetAllTickets(estado string) ([]ProyectoTicket, error) {
|
func GetAllTickets(estado string) ([]ProyectoTicket, error) {
|
||||||
var items []ProyectoTicket
|
var items []ProyectoTicket
|
||||||
db := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").Order("created_at DESC")
|
db := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").Preload("Cliente").Order("created_at DESC")
|
||||||
if estado != "" && estado != "todos" {
|
if estado != "" && estado != "todos" {
|
||||||
db = db.Where("estado = ?", estado)
|
db = db.Where("estado = ?", estado)
|
||||||
}
|
}
|
||||||
@@ -142,3 +149,18 @@ func MarkTicketMessagesReadByPortal(ticketID uint) error {
|
|||||||
Where("ticket_id = ? AND es_admin = true AND leido_portal = false", ticketID).
|
Where("ticket_id = ? AND es_admin = true AND leido_portal = false", ticketID).
|
||||||
Update("leido_portal", true).Error
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ func TestProyectoTicketAsignadoNil(t *testing.T) {
|
|||||||
|
|
||||||
func TestTicketMensajeDefaults(t *testing.T) {
|
func TestTicketMensajeDefaults(t *testing.T) {
|
||||||
msg := TicketMensaje{
|
msg := TicketMensaje{
|
||||||
TicketID: 1,
|
TicketID: 1,
|
||||||
Contenido: "Hola",
|
Contenido: "Hola",
|
||||||
AutorNombre: "Admin",
|
AutorNombre: "Admin",
|
||||||
}
|
}
|
||||||
if msg.EsAdmin {
|
if msg.EsAdmin {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type QueryHistory struct {
|
|||||||
ConxDb ConxDb `json:"conx_db" gorm:"foreignKey:ConxDbID"`
|
ConxDb ConxDb `json:"conx_db" gorm:"foreignKey:ConxDbID"`
|
||||||
UserID uint `json:"user_id" gorm:"column:user_id;index;default:0"`
|
UserID uint `json:"user_id" gorm:"column:user_id;index;default:0"`
|
||||||
SQL string `json:"sql" gorm:"column:sql;type:text"`
|
SQL string `json:"sql" gorm:"column:sql;type:text"`
|
||||||
Status string `json:"status" gorm:"column:status"` // ok | error
|
Status string `json:"status" gorm:"column:status"` // ok | error
|
||||||
ErrorMsg string `json:"error_msg" gorm:"column:error_msg;type:text"`
|
ErrorMsg string `json:"error_msg" gorm:"column:error_msg;type:text"`
|
||||||
RowsAffect int64 `json:"rows_affect" gorm:"column:rows_affect"`
|
RowsAffect int64 `json:"rows_affect" gorm:"column:rows_affect"`
|
||||||
DurationMs int64 `json:"duration_ms" gorm:"column:duration_ms"`
|
DurationMs int64 `json:"duration_ms" gorm:"column:duration_ms"`
|
||||||
|
|||||||
@@ -107,15 +107,15 @@ func DeleteServidor(servidor Servidor) error {
|
|||||||
// ── Historial de métricas ─────────────────────────────────────────────────────
|
// ── Historial de métricas ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
type ServidorMetricasHistory struct {
|
type ServidorMetricasHistory struct {
|
||||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
ServidorID uint `json:"servidor_id" gorm:"column:servidor_id;index"`
|
ServidorID uint `json:"servidor_id" gorm:"column:servidor_id;index"`
|
||||||
CreatedAt time.Time `json:"created_at" gorm:"column:created_at;index"`
|
CreatedAt time.Time `json:"created_at" gorm:"column:created_at;index"`
|
||||||
CpuPct float64 `json:"cpu_pct" gorm:"column:cpu_pct"`
|
CpuPct float64 `json:"cpu_pct" gorm:"column:cpu_pct"`
|
||||||
RamPct float64 `json:"ram_pct" gorm:"column:ram_pct"`
|
RamPct float64 `json:"ram_pct" gorm:"column:ram_pct"`
|
||||||
SwapPct float64 `json:"swap_pct" gorm:"column:swap_pct"`
|
SwapPct float64 `json:"swap_pct" gorm:"column:swap_pct"`
|
||||||
DiscoPct float64 `json:"disco_pct" gorm:"column:disco_pct"`
|
DiscoPct float64 `json:"disco_pct" gorm:"column:disco_pct"`
|
||||||
TCPConns int `json:"tcp_conns" gorm:"column:tcp_conns"`
|
TCPConns int `json:"tcp_conns" gorm:"column:tcp_conns"`
|
||||||
Load1 float64 `json:"load1" gorm:"column:load1"`
|
Load1 float64 `json:"load1" gorm:"column:load1"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ServidorMetricasHistory) TableName() string { return "servidor_metricas_history" }
|
func (ServidorMetricasHistory) TableName() string { return "servidor_metricas_history" }
|
||||||
|
|||||||
@@ -7,23 +7,51 @@ import (
|
|||||||
|
|
||||||
type SoporteWebhookConfig struct {
|
type SoporteWebhookConfig struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||||
Nombre string `json:"nombre" gorm:"column:nombre;size:100"`
|
Nombre string `json:"nombre" gorm:"column:nombre;size:100"`
|
||||||
Provider string `json:"provider" gorm:"column:provider;size:50;default:'sendgrid'"` // sendgrid|mailgun|generic
|
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"`
|
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
|
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"`
|
ResponderAuto bool `json:"responder_auto" gorm:"column:responder_auto;default:true"`
|
||||||
MensajeAuto string `json:"mensaje_auto" gorm:"column:mensaje_auto;type:text"`
|
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
|
AsignarA *uint `json:"asignar_a" gorm:"column:asignar_a;index"` // auto-asignar tickets a este user
|
||||||
|
|
||||||
// SMTP salida para notificaciones y auto-respuesta
|
// SMTP salida para notificaciones y auto-respuesta
|
||||||
SmtpHost string `json:"smtp_host" gorm:"column:smtp_host;size:255"`
|
SmtpHost string `json:"smtp_host" gorm:"column:smtp_host;size:255"`
|
||||||
SmtpPort int `json:"smtp_port" gorm:"column:smtp_port;default:587"`
|
SmtpPort int `json:"smtp_port" gorm:"column:smtp_port;default:587"`
|
||||||
SmtpUsername string `json:"smtp_username" gorm:"column:smtp_username;size:255"`
|
SmtpUsername string `json:"smtp_username" gorm:"column:smtp_username;size:255"`
|
||||||
SmtpPassword string `json:"smtp_password" gorm:"column:smtp_password;size:255"` // cifrado AES
|
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
|
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"`
|
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"`
|
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 (SoporteWebhookConfig) TableName() string { return "soporte_webhook_config" }
|
||||||
@@ -42,7 +70,13 @@ func GetAllSoporteWebhookConfigs() ([]SoporteWebhookConfig, error) {
|
|||||||
|
|
||||||
func SaveSoporteWebhookConfig(s *SoporteWebhookConfig) error {
|
func SaveSoporteWebhookConfig(s *SoporteWebhookConfig) error {
|
||||||
if s.ID > 0 {
|
if s.ID > 0 {
|
||||||
return app.Http.Database.DB.Model(s).Updates(s).Error
|
// 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
|
return app.Http.Database.DB.Create(s).Error
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-39
@@ -16,11 +16,11 @@ type StatuspageStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type StatuspagePage struct {
|
type StatuspagePage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
TimeZone string `json:"time_zone"`
|
TimeZone string `json:"time_zone"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatuspageComponent struct {
|
type StatuspageComponent struct {
|
||||||
@@ -39,50 +39,50 @@ type StatuspageComponent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type StatuspageIncidentUpdate struct {
|
type StatuspageIncidentUpdate struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatuspageIncident struct {
|
type StatuspageIncident struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"` // investigating, identified, monitoring, resolved, postmortem
|
Status string `json:"status"` // investigating, identified, monitoring, resolved, postmortem
|
||||||
Impact string `json:"impact"` // none, minor, major, critical
|
Impact string `json:"impact"` // none, minor, major, critical
|
||||||
PageID string `json:"page_id"`
|
PageID string `json:"page_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
MonitoringAt *time.Time `json:"monitoring_at"`
|
MonitoringAt *time.Time `json:"monitoring_at"`
|
||||||
ResolvedAt *time.Time `json:"resolved_at"`
|
ResolvedAt *time.Time `json:"resolved_at"`
|
||||||
ShortLink string `json:"shortlink"`
|
ShortLink string `json:"shortlink"`
|
||||||
IncidentUpdates []StatuspageIncidentUpdate `json:"incident_updates"`
|
IncidentUpdates []StatuspageIncidentUpdate `json:"incident_updates"`
|
||||||
AffectedComponents []StatuspageComponent `json:"components"`
|
AffectedComponents []StatuspageComponent `json:"components"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatuspageScheduledMaintenance struct {
|
type StatuspageScheduledMaintenance struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"` // scheduled, in_progress, verifying, completed
|
Status string `json:"status"` // scheduled, in_progress, verifying, completed
|
||||||
Impact string `json:"impact"`
|
Impact string `json:"impact"`
|
||||||
PageID string `json:"page_id"`
|
PageID string `json:"page_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
ScheduledFor time.Time `json:"scheduled_for"`
|
ScheduledFor time.Time `json:"scheduled_for"`
|
||||||
ScheduledUntil time.Time `json:"scheduled_until"`
|
ScheduledUntil time.Time `json:"scheduled_until"`
|
||||||
ShortLink string `json:"shortlink"`
|
ShortLink string `json:"shortlink"`
|
||||||
IncidentUpdates []StatuspageIncidentUpdate `json:"incident_updates"`
|
IncidentUpdates []StatuspageIncidentUpdate `json:"incident_updates"`
|
||||||
AffectedComponents []StatuspageComponent `json:"components"`
|
AffectedComponents []StatuspageComponent `json:"components"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatuspageSummary es la respuesta completa del endpoint /api/v2/summary.json
|
// StatuspageSummary es la respuesta completa del endpoint /api/v2/summary.json
|
||||||
type StatuspageSummary struct {
|
type StatuspageSummary struct {
|
||||||
Page StatuspagePage `json:"page"`
|
Page StatuspagePage `json:"page"`
|
||||||
Status StatuspageStatus `json:"status"`
|
Status StatuspageStatus `json:"status"`
|
||||||
Components []StatuspageComponent `json:"components"`
|
Components []StatuspageComponent `json:"components"`
|
||||||
Incidents []StatuspageIncident `json:"incidents"`
|
Incidents []StatuspageIncident `json:"incidents"`
|
||||||
ScheduledMaintenances []StatuspageScheduledMaintenance `json:"scheduled_maintenances"`
|
ScheduledMaintenances []StatuspageScheduledMaintenance `json:"scheduled_maintenances"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchStatuspageSummary consulta la API pública de Atlassian Statuspage.
|
// FetchStatuspageSummary consulta la API pública de Atlassian Statuspage.
|
||||||
|
|||||||
@@ -82,3 +82,14 @@ func DeleteSumodule(id uint) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,30 @@ type Tarea struct {
|
|||||||
|
|
||||||
func (Tarea) TableName() string { return "tarea" }
|
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 {
|
type TareaComentario struct {
|
||||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
TareaID uint `json:"tarea_id" gorm:"column:tarea_id;index"`
|
TareaID uint `json:"tarea_id" gorm:"column:tarea_id;index"`
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
type TelegramAgentHistory struct {
|
type TelegramAgentHistory struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
ChatID int64 `json:"chat_id" gorm:"column:chat_id;index;not null"`
|
ChatID int64 `json:"chat_id" gorm:"column:chat_id;index;not null"`
|
||||||
Role string `json:"role" gorm:"column:role;not null"` // user | assistant | tool
|
Role string `json:"role" gorm:"column:role;not null"` // user | assistant | tool
|
||||||
Content string `json:"content" gorm:"column:content;type:text;not null"`
|
Content string `json:"content" gorm:"column:content;type:text;not null"`
|
||||||
// ToolName y ToolResult se usan para mensajes de tipo "tool"
|
// ToolName y ToolResult se usan para mensajes de tipo "tool"
|
||||||
ToolName string `json:"tool_name" gorm:"column:tool_name"`
|
ToolName string `json:"tool_name" gorm:"column:tool_name"`
|
||||||
|
|||||||
@@ -0,0 +1,406 @@
|
|||||||
|
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.
|
||||||
|
// GetUmindChunksByDocumento devuelve los fragmentos de UNA fuente, en el orden
|
||||||
|
// en que se guardaron. Es lo que deja ver qué se leyó de verdad de un sitio o
|
||||||
|
// un archivo: sin esto, una URL crawleada era una caja negra — "9 fragmentos"
|
||||||
|
// y ninguna forma de saber si el crawler leyó los precios o el pie de página.
|
||||||
|
func GetUmindChunksByDocumento(documentoID uint) ([]UmindChunk, error) {
|
||||||
|
var items []UmindChunk
|
||||||
|
err := app.Http.Database.DB.
|
||||||
|
Where("documento_id = ?", documentoID).
|
||||||
|
Order("id ASC").
|
||||||
|
Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
// Ultimo es la última actividad: es lo que ordena la bandeja del panel.
|
||||||
|
// El reporte mensual no lo usa (ordena por inicio) y ahí queda en cero.
|
||||||
|
Ultimo time.Time `json:"ultimo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUmindSesionesPanel es la bandeja de conversaciones del panel: las más
|
||||||
|
// recientes primero, con lo que el visitante preguntó al abrir — no con la
|
||||||
|
// última respuesta del bot, que era lo que se mostraba antes y no decía nada
|
||||||
|
// ("¡Hola! ¿En qué te ayudo?" cincuenta veces).
|
||||||
|
func GetUmindSesionesPanel(agenteID uint, limit int) ([]SesionResumen, error) {
|
||||||
|
var items []SesionResumen
|
||||||
|
err := app.Http.Database.DB.Raw(`
|
||||||
|
SELECT
|
||||||
|
m.session_id,
|
||||||
|
MIN(m.created_at) AS inicio,
|
||||||
|
MAX(m.created_at) AS ultimo,
|
||||||
|
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.deleted_at IS NULL
|
||||||
|
GROUP BY m.session_id, m.agente_id
|
||||||
|
ORDER BY ultimo DESC
|
||||||
|
LIMIT ?
|
||||||
|
`, agenteID, limit).Scan(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UmindAccionPendiente es una acción que el agente preparó pero no ejecutó,
|
||||||
|
// esperando el visto bueno de una persona.
|
||||||
|
//
|
||||||
|
// No es un motor de workflows: es una tabla y una regla. Las acciones que
|
||||||
|
// salen hacia afuera (mandar un correo, entregar una cotización) pedidas por
|
||||||
|
// alguien que no es el dueño no se ejecutan — se encolan.
|
||||||
|
type UmindAccionPendiente struct {
|
||||||
|
gorm.Model
|
||||||
|
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index;not null"`
|
||||||
|
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index"`
|
||||||
|
Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // correo | documento | herramienta
|
||||||
|
// Herramienta y ArgumentosJSON son el payload EXACTO que se va a ejecutar.
|
||||||
|
// Al aprobar se ejecuta esto y no se le vuelve a preguntar al modelo: si
|
||||||
|
// se regenerara, aprobarías una cosa y saldría otra.
|
||||||
|
Herramienta string `json:"herramienta" gorm:"column:herramienta;size:60;not null"`
|
||||||
|
ArgumentosJSON string `json:"-" gorm:"column:argumentos_json;type:text"`
|
||||||
|
// Resumen es lo que lee la persona. Un botón "Aprobar" sobre un JSON es
|
||||||
|
// una firma en blanco.
|
||||||
|
Resumen string `json:"resumen" gorm:"column:resumen;type:text"`
|
||||||
|
SessionID string `json:"session_id" gorm:"column:session_id;size:120"` // para poder responderle a quien lo pidió
|
||||||
|
// ArchivoID apunta a la vista previa cuando la acción produce un documento.
|
||||||
|
ArchivoID *uint `json:"archivo_id" gorm:"column:archivo_id"`
|
||||||
|
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente';index"`
|
||||||
|
ResueltaPor string `json:"resuelta_por" gorm:"column:resuelta_por;size:120"`
|
||||||
|
ResueltaAt *time.Time `json:"resuelta_at" gorm:"column:resuelta_at"`
|
||||||
|
Motivo string `json:"motivo" gorm:"column:motivo;type:text"`
|
||||||
|
ExpiraAt time.Time `json:"expira_at" gorm:"column:expira_at;index"`
|
||||||
|
ResultadoJSON string `json:"resultado_json" gorm:"column:resultado_json;type:text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UmindAccionPendiente) TableName() string { return "umind_acciones_pendientes" }
|
||||||
|
|
||||||
|
// UmindAccionVigencia es cuánto vale un pendiente. Una cotización aprobada
|
||||||
|
// tres semanas tarde llega con precios de otro mes: es peor que ninguna.
|
||||||
|
const UmindAccionVigencia = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// UmindAccionMax acota los pendientes por agente. Sin tope, un visitante
|
||||||
|
// insistente llena la bandeja del dueño y la vuelve inútil.
|
||||||
|
const UmindAccionMax = 100
|
||||||
|
|
||||||
|
func CreateUmindAccion(a *UmindAccionPendiente) error {
|
||||||
|
var n int64
|
||||||
|
app.Http.Database.DB.Model(&UmindAccionPendiente{}).
|
||||||
|
Where("agente_id = ? AND estado = ? AND deleted_at IS NULL", a.AgenteID, "pendiente").Count(&n)
|
||||||
|
if n >= UmindAccionMax {
|
||||||
|
return gorm.ErrInvalidData
|
||||||
|
}
|
||||||
|
if a.ExpiraAt.IsZero() {
|
||||||
|
a.ExpiraAt = time.Now().Add(UmindAccionVigencia)
|
||||||
|
}
|
||||||
|
return app.Http.Database.DB.Create(a).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUmindAccionByID(id uint) (*UmindAccionPendiente, error) {
|
||||||
|
var a UmindAccionPendiente
|
||||||
|
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUmindAccionesPendientes lista la bandeja del espacio. Es del espacio y no
|
||||||
|
// de un agente: aprobar es tarea de una persona, y las tareas van en una sola
|
||||||
|
// bandeja — con tres agentes, tres pantallas significan que nadie revisa.
|
||||||
|
func GetUmindAccionesPendientes(tenantID uint, incluirResueltas bool) ([]UmindAccionPendiente, error) {
|
||||||
|
var items []UmindAccionPendiente
|
||||||
|
q := app.Http.Database.DB.Where("tenant_id = ?", tenantID)
|
||||||
|
if !incluirResueltas {
|
||||||
|
q = q.Where("estado = ?", "pendiente")
|
||||||
|
}
|
||||||
|
err := q.Order("created_at DESC").Limit(200).Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ContarUmindAccionesPendientes(tenantID uint) int64 {
|
||||||
|
var n int64
|
||||||
|
app.Http.Database.DB.Model(&UmindAccionPendiente{}).
|
||||||
|
Where("tenant_id = ? AND estado = ? AND deleted_at IS NULL", tenantID, "pendiente").Count(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReclamarAccion toma la acción para ejecutarla, una sola vez. Dos personas
|
||||||
|
// mirando la misma bandeja pueden apretar "Aprobar" a la vez; sin esto la
|
||||||
|
// cotización sale dos veces.
|
||||||
|
func ReclamarAccion(id uint) bool {
|
||||||
|
res := app.Http.Database.DB.Model(&UmindAccionPendiente{}).
|
||||||
|
Where("id = ? AND estado = ?", id, "pendiente").
|
||||||
|
Update("estado", "ejecutando")
|
||||||
|
return res.Error == nil && res.RowsAffected == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func CerrarAccion(id uint, estado, quien, motivo, resultado string) error {
|
||||||
|
ahora := time.Now()
|
||||||
|
return app.Http.Database.DB.Model(&UmindAccionPendiente{}).Where("id = ?", id).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"estado": estado, "resuelta_por": quien, "resuelta_at": &ahora,
|
||||||
|
"motivo": motivo, "resultado_json": resultado,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// VencerAccionesViejas cierra las que nadie miró a tiempo.
|
||||||
|
func VencerAccionesViejas() int64 {
|
||||||
|
res := app.Http.Database.DB.Model(&UmindAccionPendiente{}).
|
||||||
|
Where("estado = ? AND expira_at < ?", "pendiente", time.Now()).
|
||||||
|
Update("estado", "vencida")
|
||||||
|
return res.RowsAffected
|
||||||
|
}
|
||||||
|
|
||||||
|
// VincularArchivoAAccion engancha el documento producido a la acción.
|
||||||
|
func VincularArchivoAAccion(accionID, archivoID uint) {
|
||||||
|
app.Http.Database.DB.Model(&UmindAccionPendiente{}).Where("id = ?", accionID).
|
||||||
|
Update("archivo_id", archivoID)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UmindArchivo es el repositorio del espacio: contratos firmados, pólizas,
|
||||||
|
// cotizaciones viejas. Vive a nivel tenant y no de agente, porque los archivos
|
||||||
|
// son del negocio — si mañana crea un segundo agente, sus papeles no se mudan.
|
||||||
|
type UmindArchivo struct {
|
||||||
|
gorm.Model
|
||||||
|
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
|
||||||
|
Nombre string `json:"nombre" gorm:"column:nombre;size:255;not null"`
|
||||||
|
Archivo string `json:"-" gorm:"column:archivo;size:500;not null"` // ruta en disco, nunca al cliente
|
||||||
|
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime;size:120"`
|
||||||
|
Tamanio int64 `json:"tamanio" gorm:"column:tamanio"`
|
||||||
|
Origen string `json:"origen" gorm:"column:origen;size:20;default:'subido'"` // subido | generado
|
||||||
|
DocumentoID *uint `json:"documento_id" gorm:"column:documento_id"` // si se usó como conocimiento
|
||||||
|
SubidoPor string `json:"subido_por" gorm:"column:subido_por;size:120"`
|
||||||
|
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UmindArchivo) TableName() string { return "umind_archivos" }
|
||||||
|
|
||||||
|
func CreateUmindArchivo(a *UmindArchivo) error {
|
||||||
|
return app.Http.Database.DB.Create(a).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUmindArchivosByTenant(tenantID uint) ([]UmindArchivo, error) {
|
||||||
|
var items []UmindArchivo
|
||||||
|
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).
|
||||||
|
Order("created_at DESC").Limit(500).Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUmindArchivoByID(id uint) (*UmindArchivo, error) {
|
||||||
|
var a UmindArchivo
|
||||||
|
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteUmindArchivo(id uint) error {
|
||||||
|
return app.Http.Database.DB.Delete(&UmindArchivo{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// EspacioUsadoUmind suma los bytes guardados por un espacio, para el tope
|
||||||
|
// del plan. Se cuenta lo que hay, no lo que se subió alguna vez.
|
||||||
|
func EspacioUsadoUmind(tenantID uint) int64 {
|
||||||
|
var total int64
|
||||||
|
app.Http.Database.DB.Model(&UmindArchivo{}).
|
||||||
|
Where("tenant_id = ? AND deleted_at IS NULL", tenantID).
|
||||||
|
Select("COALESCE(SUM(tamanio), 0)").Scan(&total)
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// VincularArchivoADocumento deja constancia de que este archivo ya se cargó
|
||||||
|
// como conocimiento, para no ingerir dos veces el mismo PDF.
|
||||||
|
func VincularArchivoADocumento(archivoID, documentoID uint) error {
|
||||||
|
return app.Http.Database.DB.Model(&UmindArchivo{}).Where("id = ?", archivoID).
|
||||||
|
Update("documento_id", documentoID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AhoraUnix da el prefijo de tiempo para los nombres de archivo en disco.
|
||||||
|
func AhoraUnix() int64 { return time.Now().Unix() }
|
||||||
|
|
||||||
|
// HayEspacioUmind dice si entra un archivo más en el espacio del plan.
|
||||||
|
func HayEspacioUmind(tenantID uint, bytes int64) error {
|
||||||
|
tenant, err := GetUmindTenantByID(tenantID)
|
||||||
|
if err != nil || tenant.PlanID == nil {
|
||||||
|
return nil // sin plan asignado no se limita
|
||||||
|
}
|
||||||
|
plan, err := GetUmindPlanByID(*tenant.PlanID)
|
||||||
|
if err != nil || plan.MaxAlmacenamientoMB <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if EspacioUsadoUmind(tenantID)+bytes > int64(plan.MaxAlmacenamientoMB)<<20 {
|
||||||
|
return fmt.Errorf("el espacio de archivos del plan está lleno (%d MB)", plan.MaxAlmacenamientoMB)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UmindAviso es un recordatorio que el propio dueño le pidió al agente:
|
||||||
|
// "avisame el 15 de marzo que vence la póliza de Acme, y todos los años".
|
||||||
|
// Vuelve por donde se pidió — nunca a una dirección que dicte la conversación.
|
||||||
|
type UmindAviso struct {
|
||||||
|
gorm.Model
|
||||||
|
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index;not null"`
|
||||||
|
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index"` // desnormalizado: listar por espacio sin join
|
||||||
|
Titulo string `json:"titulo" gorm:"column:titulo;size:200;not null"`
|
||||||
|
Detalle string `json:"detalle" gorm:"column:detalle;type:text"`
|
||||||
|
// ProximoAt es cuándo toca. En repeticiones se corre solo hacia adelante.
|
||||||
|
ProximoAt time.Time `json:"proximo_at" gorm:"column:proximo_at;index;not null"`
|
||||||
|
Repetir string `json:"repetir" gorm:"column:repetir;size:20"` // "" | diario | semanal | mensual | anual
|
||||||
|
// Destino es el sessionID de donde salió el pedido ("tg:123", "mail:a@b").
|
||||||
|
// Vacío = al dueño del espacio por correo.
|
||||||
|
Destino string `json:"destino" gorm:"column:destino;size:120"`
|
||||||
|
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente';index"`
|
||||||
|
UltimoEnvioAt *time.Time `json:"ultimo_envio_at" gorm:"column:ultimo_envio_at"`
|
||||||
|
Error string `json:"error" gorm:"column:error;type:text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UmindAviso) TableName() string { return "umind_avisos" }
|
||||||
|
|
||||||
|
// UmindAvisoMax acota cuántos avisos pendientes puede tener un agente. Sin
|
||||||
|
// tope, una conversación en bucle programa mil recordatorios.
|
||||||
|
const UmindAvisoMax = 50
|
||||||
|
|
||||||
|
func CreateUmindAviso(a *UmindAviso) error {
|
||||||
|
var n int64
|
||||||
|
app.Http.Database.DB.Model(&UmindAviso{}).
|
||||||
|
Where("agente_id = ? AND estado = ? AND deleted_at IS NULL", a.AgenteID, "pendiente").Count(&n)
|
||||||
|
if n >= UmindAvisoMax {
|
||||||
|
return fmt.Errorf("este agente ya tiene el máximo de %d avisos programados", UmindAvisoMax)
|
||||||
|
}
|
||||||
|
return app.Http.Database.DB.Create(a).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUmindAvisosByAgente(agenteID uint) ([]UmindAviso, error) {
|
||||||
|
var items []UmindAviso
|
||||||
|
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).
|
||||||
|
Order("proximo_at ASC").Limit(100).Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUmindAvisoByID(id uint) (*UmindAviso, error) {
|
||||||
|
var a UmindAviso
|
||||||
|
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelarUmindAviso solo cancela los pendientes: cancelar uno ya enviado no
|
||||||
|
// significa nada y ocultaría un error de identificación del agente.
|
||||||
|
func CancelarUmindAviso(id, agenteID uint) error {
|
||||||
|
return app.Http.Database.DB.Model(&UmindAviso{}).
|
||||||
|
Where("id = ? AND agente_id = ? AND estado = ?", id, agenteID, "pendiente").
|
||||||
|
Update("estado", "cancelado").Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteUmindAviso(id uint) error {
|
||||||
|
return app.Http.Database.DB.Delete(&UmindAviso{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUmindAvisosVencidos devuelve los que ya deberían haber salido.
|
||||||
|
func GetUmindAvisosVencidos() ([]UmindAviso, error) {
|
||||||
|
var items []UmindAviso
|
||||||
|
err := app.Http.Database.DB.
|
||||||
|
Where("estado = ? AND proximo_at <= ? AND deleted_at IS NULL", "pendiente", time.Now()).
|
||||||
|
Limit(200).Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReclamarAviso marca el aviso como "enviando" solo si seguía pendiente, y
|
||||||
|
// dice si el reclamo fue de este proceso. El cron corre en memoria del
|
||||||
|
// proceso, sin lock distribuido: con dos instancias del binario levantadas
|
||||||
|
// cada aviso saldría dos veces. Este UPDATE condicional es el lock — la base
|
||||||
|
// ya es el árbitro, no hace falta traer otro.
|
||||||
|
func ReclamarAviso(id uint) bool {
|
||||||
|
res := app.Http.Database.DB.Model(&UmindAviso{}).
|
||||||
|
Where("id = ? AND estado = ?", id, "pendiente").
|
||||||
|
Update("estado", "enviando")
|
||||||
|
return res.Error == nil && res.RowsAffected == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReprogramarAviso corre la fecha hacia adelante según la repetición y lo
|
||||||
|
// deja pendiente otra vez. Si no repite, queda enviado.
|
||||||
|
func ReprogramarAviso(a *UmindAviso) error {
|
||||||
|
ahora := time.Now()
|
||||||
|
updates := map[string]interface{}{"ultimo_envio_at": &ahora, "error": ""}
|
||||||
|
|
||||||
|
proximo := siguienteFecha(a.ProximoAt, a.Repetir)
|
||||||
|
if proximo.IsZero() {
|
||||||
|
updates["estado"] = "enviado"
|
||||||
|
} else {
|
||||||
|
// Si el servidor estuvo caído varios ciclos, se salta los perdidos en
|
||||||
|
// vez de disparar una ráfaga de avisos viejos.
|
||||||
|
for !proximo.After(ahora) {
|
||||||
|
proximo = siguienteFecha(proximo, a.Repetir)
|
||||||
|
}
|
||||||
|
updates["estado"] = "pendiente"
|
||||||
|
updates["proximo_at"] = proximo
|
||||||
|
}
|
||||||
|
return app.Http.Database.DB.Model(&UmindAviso{}).Where("id = ?", a.ID).Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func siguienteFecha(desde time.Time, repetir string) time.Time {
|
||||||
|
switch repetir {
|
||||||
|
case "diario":
|
||||||
|
return desde.AddDate(0, 0, 1)
|
||||||
|
case "semanal":
|
||||||
|
return desde.AddDate(0, 0, 7)
|
||||||
|
case "mensual":
|
||||||
|
return desde.AddDate(0, 1, 0)
|
||||||
|
case "anual":
|
||||||
|
return desde.AddDate(1, 0, 0)
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarcarAvisoFallido lo devuelve a pendiente para el próximo ciclo: una caída
|
||||||
|
// de SMTP no puede perder un vencimiento de póliza.
|
||||||
|
func MarcarAvisoFallido(id uint, motivo string) {
|
||||||
|
app.Http.Database.DB.Model(&UmindAviso{}).Where("id = ?", id).
|
||||||
|
Updates(map[string]interface{}{"estado": "pendiente", "error": motivo})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiguienteFechaAviso se exporta solo para poder probar el cálculo de
|
||||||
|
// repeticiones sin base de datos de por medio.
|
||||||
|
func SiguienteFechaAviso(desde time.Time, repetir string) time.Time {
|
||||||
|
return siguienteFecha(desde, repetir)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"`
|
||||||
|
// Solo para el canal correo: cada cuántos minutos se revisa la casilla.
|
||||||
|
// Lo elige el cliente — una inmobiliaria quiere 2, un estudio contable con
|
||||||
|
// 30 le sobra. Telegram y WhatsApp no lo usan: a ellos les llega webhook.
|
||||||
|
IntervaloMinutos int `json:"intervalo_minutos" gorm:"column:intervalo_minutos;default:5"`
|
||||||
|
// UltimaRevision es lo que decide si ya le toca — en la base y no en
|
||||||
|
// memoria, por la misma razón que el aviso de tope: los despliegues borran
|
||||||
|
// la RAM varias veces por día.
|
||||||
|
UltimaRevision *time.Time `json:"ultima_revision" gorm:"column:ultima_revision"`
|
||||||
|
// FiltroRemitentes acota a quién le responde el canal correo: direcciones
|
||||||
|
// o dominios separados por coma. Vacío = responde a todos. Un correo que
|
||||||
|
// no pasa el filtro se marca leído y no se contesta — el agente decidió,
|
||||||
|
// no está pendiente.
|
||||||
|
FiltroRemitentes string `json:"filtro_remitentes" gorm:"column:filtro_remitentes;type:text"`
|
||||||
|
// EsInterno marca que este canal es la línea privada del dueño y no una
|
||||||
|
// puerta a sus clientes. En un canal interno el agente ejecuta acciones
|
||||||
|
// directo; en uno público toda acción queda esperando aprobación.
|
||||||
|
EsInterno bool `json:"es_interno" gorm:"column:es_interno;default:false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarcarRevisionCanal deja constancia de que la casilla se acaba de revisar.
|
||||||
|
func MarcarRevisionCanal(id uint) {
|
||||||
|
ahora := time.Now()
|
||||||
|
app.Http.Database.DB.Model(&UmindCanal{}).Where("id = ?", id).
|
||||||
|
Update("ultima_revision", &ahora)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCanalesCorreoPendientes devuelve los canales de correo activos a los que
|
||||||
|
// ya les toca revisión según su propio intervalo.
|
||||||
|
func GetCanalesCorreoPendientes() ([]UmindCanal, error) {
|
||||||
|
var items []UmindCanal
|
||||||
|
err := app.Http.Database.DB.
|
||||||
|
Where("tipo = ? AND activo = ?", "correo", true).
|
||||||
|
Where("ultima_revision IS NULL OR ultima_revision < NOW() - (intervalo_minutos || ' minutes')::interval").
|
||||||
|
Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUmindCanalPorTipo devuelve el canal activo de ese tipo del agente.
|
||||||
|
func GetUmindCanalPorTipo(agenteID uint, tipo string) (*UmindCanal, error) {
|
||||||
|
var c UmindCanal
|
||||||
|
err := app.Http.Database.DB.
|
||||||
|
Where("agente_id = ? AND tipo = ? AND activo = ?", agenteID, tipo, true).
|
||||||
|
First(&c).Error
|
||||||
|
return &c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EsSesionInterna dice si esa conversación es con el dueño y no con un
|
||||||
|
// cliente suyo. Es lo que decide si el agente puede ejecutar acciones
|
||||||
|
// directamente o si tienen que esperar aprobación: en el widget público
|
||||||
|
// escribe cualquiera, y "cualquiera" no puede programar avisos ni gastar
|
||||||
|
// el plan del dueño.
|
||||||
|
func EsSesionInterna(agenteID uint, sessionID string) bool {
|
||||||
|
// El chat de prueba del panel: del otro lado hay una sesión autenticada.
|
||||||
|
if strings.HasPrefix(sessionID, "panel:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var tipo string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(sessionID, "tg:"):
|
||||||
|
tipo = "telegram"
|
||||||
|
case strings.HasPrefix(sessionID, "mail:"):
|
||||||
|
tipo = "correo"
|
||||||
|
case strings.HasPrefix(sessionID, "wa:"):
|
||||||
|
tipo = "whatsapp"
|
||||||
|
default:
|
||||||
|
return false // widget web: siempre público
|
||||||
|
}
|
||||||
|
canal, err := GetUmindCanalPorTipo(agenteID, tipo)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return canal.EsInterno
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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 | imap
|
||||||
|
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"`
|
||||||
|
// CredencialesEnc guarda el blob cifrado de una cuenta IMAP (host, puerto,
|
||||||
|
// usuario, contraseña, encriptación) — mismo esquema que los canales. Las
|
||||||
|
// conexiones OAuth no lo usan: sus tokens van en los campos de arriba.
|
||||||
|
CredencialesEnc string `json:"-" gorm:"column:credenciales_enc;type:text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
// GetUmindConexionesActivas lista todas las cuentas conectadas de un agente.
|
||||||
|
// Antes se soportaba una sola; ahora un agente puede tener varias casillas y
|
||||||
|
// el modelo elige por dirección cuando el dueño se lo pide.
|
||||||
|
func GetUmindConexionesActivas(agenteID uint) ([]UmindConexion, error) {
|
||||||
|
var items []UmindConexion
|
||||||
|
err := app.Http.Database.DB.
|
||||||
|
Where("agente_id = ? AND activo = ?", agenteID, true).
|
||||||
|
Order("id ASC").Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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"`
|
||||||
|
// PrecioPorDocumento cobra cada PDF generado: cada uno levanta un Chrome
|
||||||
|
// headless, que es el recurso más caro del servidor.
|
||||||
|
PrecioPorDocumento float64 `json:"precio_por_documento" gorm:"column:precio_por_documento;default:0"`
|
||||||
|
// MaxAlmacenamientoMB acota el repositorio de archivos del espacio.
|
||||||
|
// 0 = sin límite.
|
||||||
|
MaxAlmacenamientoMB int `json:"max_almacenamiento_mb" gorm:"column:max_almacenamiento_mb;default:200"`
|
||||||
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UmindPlan) TableName() string { return "umind_planes" }
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user