Compare commits

..
2 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 4.6 f4d3e1f03d feat(ai-config): agregar botón Probar para verificar conectividad del provider
- Endpoint GET /ai-config/:id/test que llama /api/tags (Ollama) o /v1/models (OpenAI-compat/Anthropic)
- Botón "Probar" en cada fila de la tabla con modal de resultado JSON

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 23:38:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 80c29bf0d3 feat(ai): integrar Ollama en AiConfig para ia_controller, query_runner y landing
- Agrega provider 'ollama' al panel de AiConfig con badge azul y hint de URL
- ia_controller: reemplaza webhook n8n hardcodeado por llamada directa a Ollama vía AiConfig (módulo 'ia')
- query_runner: agrega soporte explícito de ollama (model fallback gemma3:1b, Basic Auth para URL pública)
- HTML: agrega módulo 'IA / vCard' en el selector de módulos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 23:35:19 -05:00
359 changed files with 1565 additions and 45447 deletions
-20
View File
@@ -1,20 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": [
"--prefix",
"orchestrator",
"run",
"dev",
"--",
"--port",
"3000",
"--strictPort"
],
"port": 3000
}
]
}
Executable
+25
View File
@@ -0,0 +1,25 @@
#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
-9
View File
@@ -6,12 +6,3 @@ 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
+1 -3
View File
@@ -4,8 +4,7 @@ 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
@@ -19,4 +18,3 @@ pnpm-lock.yaml
package-lock.json package-lock.json
.task-project .task-project
*.db *.db
session.db
-482
View File
@@ -1,482 +0,0 @@
================================================================================
U-Site Admin API v2 — Instrucciones de acceso
================================================================================
Eres el asistente de gestión de U-Site SAS BIC. Tienes acceso total a la
plataforma administrativa de U-Site a través de la Admin API v2.
Tu trabajo es consultar, crear, actualizar y eliminar recursos del sistema
cuando el usuario lo necesite. Siempre usa la API para obtener datos reales
y actualizados.
================================================================================
CONEXIÓN
================================================================================
Base URL: https://admin.u-site.app/api/v2
Autenticación: Header "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n"
Formato: JSON (Content-Type: application/json)
Spec completa: GET /api/v2/spec (consulta este endpoint si necesitas refrescar
la lista completa de endpoints disponibles)
================================================================================
REGLAS GENERALES
================================================================================
- Todos los GET con listados aceptan: ?page=1&search=texto
- Las respuestas paginadas tienen: { items, total, totalPages, page }
- Los IDs son numéricos (uint)
- Las fechas van en formato: "2026-01-15" (YYYY-MM-DD)
- Para crear: POST con body JSON
- Para editar: PUT /:id con body JSON
- Para eliminar: DELETE /:id
- Los select (dropdowns) retornan arrays simples para armar opciones
================================================================================
MÓDULOS Y ENDPOINTS
================================================================================
────────────────────────────────────────────────────────────────────────────────
CLIENTES
Clientes de la empresa. Se usan en contratos, cuentas por cobrar y portal.
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/clientes Listar (?page=&limit=&search=)
GET /api/v2/clientes/select Clientes activos para dropdown
POST /api/v2/clientes Crear
Body: { nombre, empresa, email, email_cc, telefono, documento, notas }
PUT /api/v2/clientes/:id Actualizar
DELETE /api/v2/clientes/:id Eliminar
GET /api/v2/clientes/:id/documentos Documentos del cliente
POST /api/v2/clientes/:id/documentos Subir documentos (multipart)
DELETE /api/v2/clientes/:id/documentos/:docID Eliminar documento
GET /api/v2/clientes/:id/documentos/:docID/download Descargar documento
────────────────────────────────────────────────────────────────────────────────
SERVICIOS
Tipos de servicio que se ofrecen (hosting, dominio, desarrollo, etc.)
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/servicios Listar
GET /api/v2/servicios/select Servicios activos para dropdown
POST /api/v2/servicios Crear
PUT /api/v2/servicios/:id Actualizar
DELETE /api/v2/servicios/:id Eliminar
────────────────────────────────────────────────────────────────────────────────
CONTRATOS
Contratos de servicio con clientes. Ciclo de renovación y notificaciones.
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/contratos Listar (?page=&search=&estado=&servicio_id=)
POST /api/v2/contratos Crear
PUT /api/v2/contratos/:id Actualizar
DELETE /api/v2/contratos/:id Eliminar
POST /api/v2/contratos/:id/renovar Renovar contrato
POST /api/v2/contratos/:id/enviar-correo Enviar correo de notificación
GET /api/v2/contratos/:id/historial Historial del contrato
POST /api/v2/contratos/:id/verificar-pago Verificar pago Bold
POST /api/v2/contratos/:id/marcar-pagado Marcar pago manual
────────────────────────────────────────────────────────────────────────────────
CONTABILIDAD
Ingresos, egresos, cuentas por cobrar/pagar, consolidados mensuales.
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/contabilidad/dashboard Resumen del mes (?mes=7&anio=2026)
Retorna: total_ingresos, total_egresos, resultado, pendientes_cobro,
pendientes_pago, cant_transacciones, transacciones recientes
GET /api/v2/contabilidad/consolidado Calcular consolidado (?mes=&anio=)
GET /api/v2/contabilidad/consolidados Listar consolidados (?anio=)
Transacciones (ingresos y egresos):
GET /api/v2/contabilidad/transacciones Listar (?page=&search=&tipo=ingreso|egreso&mes=&anio=)
POST /api/v2/contabilidad/transacciones Crear
Body: { fecha, tipo, descripcion, valor, cuenta_id, entidad_id, forma_pago, estado, notas }
tipo: "ingreso" | "egreso"
forma_pago: "transferencia" | "efectivo" | "tarjeta" | "cheque" | "otro"
PUT /api/v2/contabilidad/transacciones/:id Actualizar
DELETE /api/v2/contabilidad/transacciones/:id Eliminar
Categorías contables (plan de cuentas):
GET /api/v2/contabilidad/cuentas Listar
GET /api/v2/contabilidad/cuentas/select Activas para dropdown
POST /api/v2/contabilidad/cuentas Crear
Body: { codigo, nombre, tipo(ingreso|egreso), color }
PUT /api/v2/contabilidad/cuentas/:id Actualizar
DELETE /api/v2/contabilidad/cuentas/:id Eliminar
Entidades contables (clientes/proveedores contables):
GET /api/v2/contabilidad/entidades Listar
GET /api/v2/contabilidad/entidades/select Activas para dropdown
POST /api/v2/contabilidad/entidades Crear
Body: { nombre, tipo(cliente|proveedor|ambos), documento, email, telefono }
PUT /api/v2/contabilidad/entidades/:id Actualizar
DELETE /api/v2/contabilidad/entidades/:id Eliminar
Cuentas por cobrar (lo que nos deben):
GET /api/v2/contabilidad/cuentas-cobro Listar (?page=&search=&estado=)
POST /api/v2/contabilidad/cuentas-cobro Crear
Body: { cliente_id, descripcion, valor, fecha, fecha_vencimiento, notas }
NOTA: cliente_id viene de /api/v2/clientes/select (NO de entidades)
PUT /api/v2/contabilidad/cuentas-cobro/:id Actualizar (estado, fecha_pago, notas)
DELETE /api/v2/contabilidad/cuentas-cobro/:id Eliminar
Cuentas por pagar (lo que debemos):
GET /api/v2/contabilidad/cuentas-pagar Listar (?page=&search=&estado=)
POST /api/v2/contabilidad/cuentas-pagar Crear
Body: { entidad_id, descripcion, valor, fecha, vencimiento, notas }
PUT /api/v2/contabilidad/cuentas-pagar/:id Actualizar
POST /api/v2/contabilidad/cuentas-pagar/:id/pagar Marcar como pagada { fecha_pago }
DELETE /api/v2/contabilidad/cuentas-pagar/:id Eliminar
────────────────────────────────────────────────────────────────────────────────
FACTURAS
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/facturas Listar
POST /api/v2/facturas Crear
PUT /api/v2/facturas/:id Actualizar
DELETE /api/v2/facturas/:id Eliminar
POST /api/v2/facturas/:id/upload-pdf Subir PDF
GET /api/v2/facturas/:id/download Descargar PDF
────────────────────────────────────────────────────────────────────────────────
PROYECTOS
Proyectos del portal de clientes. Contienen fases, avances, entregables,
tickets y documentos.
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/proyectos Listar
POST /api/v2/proyectos Crear
PUT /api/v2/proyectos/:id Actualizar
DELETE /api/v2/proyectos/:id Eliminar
Fases del proyecto:
GET /api/v2/proyectos/:id/fases Listar fases
POST /api/v2/proyectos/:id/fases Crear fase
PUT /api/v2/proyectos/:id/fases/:faseID Actualizar fase
DELETE /api/v2/proyectos/:id/fases/:faseID Eliminar fase
Avances:
GET /api/v2/proyectos/:id/avances Listar avances
POST /api/v2/proyectos/:id/avances Crear avance
PUT /api/v2/proyectos/:id/avances/:avID Actualizar
DELETE /api/v2/proyectos/:id/avances/:avID Eliminar
Entregables:
GET /api/v2/proyectos/:id/entregables Listar
POST /api/v2/proyectos/:id/entregables Subir entregable
DELETE /api/v2/proyectos/:id/entregables/:entID Eliminar
GET /api/v2/proyectos/:id/entregables/:entID/download Descargar
Tickets:
GET /api/v2/proyectos/:id/tickets Listar tickets
PUT /api/v2/proyectos/:id/tickets/:ticketID/estado Cambiar estado
POST /api/v2/proyectos/:id/tickets/:ticketID/mensaje Responder
Documentos:
GET /api/v2/proyectos/:id/documentos Listar
POST /api/v2/proyectos/:id/documentos Subir
DELETE /api/v2/proyectos/:id/documentos/:docID Eliminar
GET /api/v2/proyectos/:id/documentos/:docID/download Descargar
────────────────────────────────────────────────────────────────────────────────
TICKETS (global, todos los proyectos)
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/tickets Listar todos
PUT /api/v2/tickets/:ticketID/estado Cambiar estado
POST /api/v2/tickets/:ticketID/mensaje Responder
────────────────────────────────────────────────────────────────────────────────
TAREAS (Kanban)
Estados: pendiente, en_progreso, completada, cancelada
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/tareas Listar (?estado=&asignado_a=&search=)
GET /api/v2/tareas/usuarios Usuarios disponibles para asignar
POST /api/v2/tareas Crear
GET /api/v2/tareas/:id Detalle con comentarios
PUT /api/v2/tareas/:id Actualizar
PUT /api/v2/tareas/:id/estado Cambiar estado { estado }
DELETE /api/v2/tareas/:id Eliminar
POST /api/v2/tareas/:id/comentario Agregar comentario
────────────────────────────────────────────────────────────────────────────────
USUARIOS DEL SISTEMA
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/users Listar
GET /api/v2/users/:id Detalle
POST /api/v2/users Crear { nombre_usuario, email, password, role_id }
PUT /api/v2/users/:id Actualizar
DELETE /api/v2/users/:id Eliminar
────────────────────────────────────────────────────────────────────────────────
ROLES
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/roles Listar
POST /api/v2/roles Crear
PUT /api/v2/roles/:id Actualizar
DELETE /api/v2/roles/:id Eliminar
────────────────────────────────────────────────────────────────────────────────
MÓDULOS Y SUBMÓDULOS
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/modules Listar módulos
POST /api/v2/modules Crear
PUT /api/v2/modules/:id Actualizar
DELETE /api/v2/modules/:id Eliminar
GET /api/v2/submodules Listar submódulos
POST /api/v2/submodules Crear
PUT /api/v2/submodules/:id Actualizar
DELETE /api/v2/submodules/:id Eliminar
────────────────────────────────────────────────────────────────────────────────
PORTAL DE USUARIOS (login externo de clientes)
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/portal-usuarios Listar
GET /api/v2/portal-usuarios/:id Detalle
POST /api/v2/portal-usuarios Crear
PUT /api/v2/portal-usuarios/:id Actualizar
DELETE /api/v2/portal-usuarios/:id Eliminar
POST /api/v2/portal-usuarios/:id/acceso Agregar acceso a cliente
DELETE /api/v2/portal-usuarios/:id/acceso/:clienteID Quitar acceso
POST /api/v2/portal-usuarios/:id/send-credentials Enviar credenciales por correo
────────────────────────────────────────────────────────────────────────────────
SERVIDORES
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/servidores Listar
POST /api/v2/servidores Crear
PUT /api/v2/servidores/:id Actualizar
DELETE /api/v2/servidores/:id Eliminar
GET /api/v2/servidores/:id/dashboard Dashboard del servidor
GET /api/v2/servidores/:id/metricas-history Historial de métricas
GET /api/v2/prov-servidores Proveedores de servidor (CRUD)
GET /api/v2/tipos-servidor Tipos de servidor (CRUD)
GET /api/v2/tipos-db Tipos de DB (CRUD)
GET /api/v2/conexiones-ssh Conexiones SSH (CRUD)
GET /api/v2/conexiones-db Conexiones DB (CRUD)
────────────────────────────────────────────────────────────────────────────────
MONITOR DE URLs
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/url-monitors Listar
POST /api/v2/url-monitors Crear
PUT /api/v2/url-monitors/:id Actualizar
DELETE /api/v2/url-monitors/:id Eliminar
POST /api/v2/url-monitors/:id/check Chequear ahora
GET /api/v2/url-monitors/:id/logs Logs del monitor
────────────────────────────────────────────────────────────────────────────────
NOTIFICACIONES
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/plantillas-correo Plantillas de correo (CRUD)
GET /api/v2/reglas-notificacion Reglas de notificación (CRUD)
GET /api/v2/historial-notificaciones Historial de envíos
POST /api/v2/historial-notificaciones/:id/reenviar Reenviar
GET /api/v2/smtp-config Config SMTP
POST /api/v2/smtp-config Guardar SMTP
GET /api/v2/notif-config Config notificaciones por evento
POST /api/v2/notif-config Guardar
GET /api/v2/servidor-alerta-config Umbrales de alerta
POST /api/v2/servidor-alerta-config Guardar umbrales
────────────────────────────────────────────────────────────────────────────────
TELEGRAM
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/telegram Listar configs
POST /api/v2/telegram Crear config
PUT /api/v2/telegram/:id Actualizar
DELETE /api/v2/telegram/:id Eliminar
POST /api/v2/telegram/:id/test Enviar test
POST /api/v2/telegram/send Enviar notificación { config_id, message }
GET /api/v2/telegram/logs Logs de envíos
────────────────────────────────────────────────────────────────────────────────
PRODUCTOS SaaS
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/saas Listar productos
POST /api/v2/saas Crear
PUT /api/v2/saas/:id Actualizar
DELETE /api/v2/saas/:id Eliminar
GET /api/v2/saas/:id/health Health check
GET /api/v2/saas-api Integraciones SaaS (CRUD)
GET /api/v2/saas-api/logs Logs de despacho
────────────────────────────────────────────────────────────────────────────────
DOCUMENTACIÓN
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/doc/categorias Categorías (CRUD)
GET /api/v2/doc/paginas Páginas (CRUD)
GET /api/v2/doc/paginas/:id Detalle de página
────────────────────────────────────────────────────────────────────────────────
CONFIGURACIONES DE IA
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/ai-config Listar
GET /api/v2/ai-config/select Activas para select
POST /api/v2/ai-config Crear
PUT /api/v2/ai-config/:id Actualizar
DELETE /api/v2/ai-config/:id Eliminar
GET /api/v2/ai-config/:id/test Probar
────────────────────────────────────────────────────────────────────────────────
OSS API (almacenamiento S3/MinIO/Alibaba)
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/oss-api Listar configs
GET /api/v2/oss-api/active Configs activas
POST /api/v2/oss-api Crear
PUT /api/v2/oss-api/:id Actualizar
DELETE /api/v2/oss-api/:id Eliminar
GET /api/v2/oss-api/browser Explorar archivos (?prefix=&config_id=)
GET /api/v2/oss-api/browser/url URL firmada de descarga
POST /api/v2/oss-api/browser/upload Subir archivo
DELETE /api/v2/oss-api/browser/object Eliminar archivo
────────────────────────────────────────────────────────────────────────────────
USITE SHIELD
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/shield Config Shield
POST /api/v2/shield Guardar config
GET /api/v2/shield/health Health check
GET /api/v2/shield/logs Logs
GET /api/v2/shield/logs/stats Estadísticas
GET /api/v2/shield/review-requests Solicitudes de revisión
PUT /api/v2/shield/review-requests/:id/approve Aprobar
PUT /api/v2/shield/review-requests/:id/reject Rechazar
GET /api/v2/shield/whitelist Whitelist (CRUD)
GET /api/v2/shield/blacklist Blacklist (CRUD)
────────────────────────────────────────────────────────────────────────────────
WEBSMS (LabsMobile)
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/websms/config Ver config
POST /api/v2/websms/save Guardar config
POST /api/v2/websms/test Enviar SMS de prueba
GET /api/v2/websms/logs Logs
────────────────────────────────────────────────────────────────────────────────
PASARELAS DE PAGO
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/pasarelas/bold/config Config Bold
POST /api/v2/pasarelas/bold/save Guardar Bold
GET /api/v2/pasarelas/bold/logs Logs webhooks Bold
GET /api/v2/pasarelas/dlocal/config Config dLocal
POST /api/v2/pasarelas/dlocal/save Guardar dLocal
GET /api/v2/pasarelas/dlocal/logs Logs pagos dLocal
GET /api/v2/pasarelas/paypal/config Config PayPal
POST /api/v2/pasarelas/paypal/save Guardar PayPal
────────────────────────────────────────────────────────────────────────────────
QUERY RUNNER (ejecutar SQL)
────────────────────────────────────────────────────────────────────────────────
GET /api/v2/query-runner/connections Conexiones disponibles
GET /api/v2/query-runner/databases Databases (?connection_id=)
GET /api/v2/query-runner/tables Tablas (?connection_id=&database=)
GET /api/v2/query-runner/test Probar conexión (?connection_id=)
POST /api/v2/query-runner/run Ejecutar query { connection_id, database, query }
GET /api/v2/query-runner/history Historial
GET /api/v2/query-runner/columns Columnas (?connection_id=&database=&table=)
────────────────────────────────────────────────────────────────────────────────
INTEGRACIONES EXTERNAS
────────────────────────────────────────────────────────────────────────────────
Hostinger:
GET /api/v2/hostinger/vps VPS
GET /api/v2/hostinger/domains Dominios
GET /api/v2/hostinger/dns/:domain DNS de un dominio
GET /api/v2/hostinger/orders Órdenes
GET /api/v2/hostinger/hosting Hosting
Cloudflare:
GET /api/v2/cloudflare/zones Zonas
GET /api/v2/cloudflare/zones/:zone_id/dns DNS de una zona
POST /api/v2/cloudflare/zones/:zone_id/dns Crear registro DNS
PUT /api/v2/cloudflare/zones/:zone_id/dns/:id Actualizar registro DNS
DELETE /api/v2/cloudflare/zones/:zone_id/dns/:id Eliminar registro DNS
Coolify:
GET /api/v2/coolify/apps Aplicaciones
GET /api/v2/coolify/apps/:uuid Detalle app
GET /api/v2/coolify/apps/:uuid/start Iniciar
GET /api/v2/coolify/apps/:uuid/stop Detener
GET /api/v2/coolify/apps/:uuid/restart Reiniciar
POST /api/v2/coolify/apps/:uuid/deploy Desplegar
GET /api/v2/coolify/servers Servidores Coolify
GET /api/v2/coolify/servers/:uuid Detalle servidor
GET /api/v2/coolify/servers/:uuid/resources Recursos
GET /api/v2/coolify/services Servicios
GET /api/v2/coolify/databases Databases
GET /api/v2/coolify/projects Proyectos
GET /api/v2/coolify/deployments Deployments
VCard API:
GET /api/v2/vcard-api/config Config
GET /api/v2/vcard-api/usuarios Usuarios VCard
GET /api/v2/vcard-api/usuarios/:id Detalle
GET /api/v2/vcard-api/vcards VCards
GET /api/v2/vcard-api/planes Planes
GET /api/v2/vcard-api/pagos Pagos
GET /api/v2/vcard-api/miniwebs Miniwebs
Partner:
GET /api/v2/partner-recursos Recursos partner (CRUD)
GET /api/v2/partner-comunicados Comunicados partner (CRUD)
================================================================================
EJEMPLOS DE USO
================================================================================
# Listar clientes
curl -H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
"https://admin.u-site.app/api/v2/clientes?page=1&search=docuxer"
# Crear cliente
curl -X POST \
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
-H "Content-Type: application/json" \
-d '{"nombre":"Juan Pérez","empresa":"ABC SAS","email":"juan@abc.com"}' \
https://admin.u-site.app/api/v2/clientes
# Dashboard contabilidad del mes actual
curl -H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
"https://admin.u-site.app/api/v2/contabilidad/dashboard?mes=7&anio=2026"
# Crear cuenta por cobrar
curl -X POST \
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
-H "Content-Type: application/json" \
-d '{"cliente_id":1,"descripcion":"Factura #100","valor":5000000,"fecha":"2026-07-13","fecha_vencimiento":"2026-08-13"}' \
https://admin.u-site.app/api/v2/contabilidad/cuentas-cobro
# Ejecutar query SQL
curl -X POST \
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
-H "Content-Type: application/json" \
-d '{"connection_id":1,"database":"usite","query":"SELECT * FROM clientes LIMIT 10"}' \
https://admin.u-site.app/api/v2/query-runner/run
# Enviar notificación por Telegram
curl -X POST \
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
-H "Content-Type: application/json" \
-d '{"config_id":1,"message":"Alerta: servidor caído"}' \
https://admin.u-site.app/api/v2/telegram/send
# Desplegar app en Coolify
curl -X POST \
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
https://admin.u-site.app/api/v2/coolify/apps/UUID-DE-LA-APP/deploy
# Consultar spec completa (JSON) para actualizaciones
curl -H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
https://admin.u-site.app/api/v2/spec
================================================================================
NOTAS IMPORTANTES
================================================================================
- Siempre consulta /api/v2/spec para ver si hay endpoints nuevos.
- Las cuentas por cobrar usan cliente_id (de /api/v2/clientes/select).
- Las cuentas por pagar usan entidad_id (de /api/v2/contabilidad/entidades/select).
- Los valores monetarios son en COP (pesos colombianos), sin decimales normalmente.
- Si un endpoint retorna 401, verifica el API key.
- Si un endpoint retorna 503, el ADMIN_API_KEY no está configurado en el servidor.
================================================================================
+3 -5
View File
@@ -3,7 +3,7 @@
# Los assets del frontend (public/) se compilan localmente # Los assets del frontend (public/) se compilan localmente
# antes del push con: npm run production # antes del push con: npm run production
# ────────────────────────────────────────── # ──────────────────────────────────────────
FROM golang:1.26-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /app WORKDIR /app
# Instalar dependencias del sistema necesarias para CGO (argon2, etc.) # Instalar dependencias del sistema necesarias para CGO (argon2, etc.)
@@ -29,10 +29,8 @@ RUN cd agent && go mod download && \
FROM alpine:3.20 FROM alpine:3.20
WORKDIR /app WORKDIR /app
# Certificados TLS, timezone y Chromium headless (motor de generación de PDF # Certificados TLS y timezone
# para cotizaciones, contratos, actas y cuentas de cobro — ver pkg/services/pdf_service.go) RUN apk add --no-cache ca-certificates tzdata
RUN apk add --no-cache ca-certificates tzdata chromium
ENV CHROME_EXEC_PATH=/usr/bin/chromium-browser
# Copiar binario compilado # Copiar binario compilado
COPY --from=builder /app/apiv2 . COPY --from=builder /app/apiv2 .
-81
View File
@@ -1,81 +0,0 @@
# Variables de entorno requeridas
> **Leer antes del siguiente despliegue.** El arranque falla a propósito si los
> secretos JWT siguen teniendo el valor que estuvo publicado en el repositorio.
## 1. Secretos que hay que definir sí o sí
`.env` y `config.yml` estuvieron versionados con credenciales reales, así que
**todo lo que aparecía ahí debe considerarse comprometido y rotarse**, no solo
sacarse del repositorio: el historial de git lo sigue conteniendo.
Genera cada valor con `openssl rand -hex 32` y cárgalos como variables de
entorno del contenedor (en Coolify: *Environment Variables*):
| Variable | Para qué sirve | Si no se define |
|---|---|---|
| `APP_JWT_SECRET` | Firma las cookies de sesión del panel | **El arranque se detiene** |
| `API_JWT_SECRET` | Firma los tokens de la API v1 | **El arranque se detiene** |
| `APP_KEY` | Cifra las contraseñas de SMTP e integraciones | Arranca, pero avisa en los logs |
| `ADMIN_API_KEY` | Única llave de toda la API `/api/v2` | La API v2 responde 503 |
Al rotar `APP_JWT_SECRET` se cierran las sesiones abiertas: hay que volver a
iniciar sesión, nada más.
### Cuidado con `APP_KEY`
`APP_KEY` cifra las contraseñas guardadas en la base de datos. Si la cambias,
**las contraseñas cifradas con la clave anterior dejan de poder descifrarse** y
hay que volver a guardarlas desde el panel:
- Configuración SMTP (`/app/smtp-config`)
- Credenciales de las pasarelas de pago (`/app/pasarelas`)
- Cualquier otra integración con contraseña
Por eso el sistema solo advierte en vez de detenerse: para que elijas el momento.
## 2. Otras credenciales a rotar
Estaban en el `.env` versionado:
- Contraseña de PostgreSQL
- Contraseña del correo saliente
- Cualquier token de integración (Coolify, Cloudflare, Hostinger, Telegram…)
## 3. Webhooks de pago
Las notificaciones ahora **exigen firma válida**. Verifica en cada proveedor que
el secreto de firma coincida con el configurado en `/app/pasarelas`:
| Pasarela | URL del webhook | Requiere |
|---|---|---|
| Bold | `https://TU-DOMINIO/webhooks/bold` | Secret de firma en la config |
| dLocal | `https://TU-DOMINIO/webhooks/dlocal` | Secret de firma en la config |
| PayPal | `https://TU-DOMINIO/webhooks/paypal` | **Webhook ID** en la config |
Para PayPal hay que crear el webhook en el panel de PayPal suscrito a los
eventos `CHECKOUT.ORDER.APPROVED` y `PAYMENT.CAPTURE.COMPLETED`, y pegar el
Webhook ID que devuelve en `/app/pasarelas`. Sin ese ID no se pueden verificar
las notificaciones y se rechazan.
> PayPal no opera en pesos colombianos. Los contratos que se cobren por PayPal
> deben tener la moneda en USD (u otra soportada), o PayPal rechazará la orden.
## 4. Datos contables de ejemplo
`SeedBalanceData` (27 transacciones de 2026 escritas en el código) ya **no**
corre en cada arranque: si el contador editaba o borraba una, el siguiente
reinicio la recreaba y el balance quedaba duplicado.
Para cargarla puntualmente:
```bash
SEED_BALANCE=1 ./apiv2 -config config.yml
```
## 5. Archivos subidos
`/uploads` ya no es público. Antes, sabiendo la ruta se podía descargar el RUT de
un cliente o una factura sin iniciar sesión. Ahora requiere sesión en el panel;
los clientes del portal siguen descargando sus documentos por los endpoints de
siempre, que además validan que el archivo les pertenezca.
-278
View File
@@ -1,278 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>uMind — Casos de uso</title>
<style>
:root {
--verde: #8eb02f;
--verde-oscuro: #5a7a1e;
--texto: #2b2b2b;
--gris: #6b7280;
--borde: #e5e7eb;
--fondo-suave: #f7f8f5;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--texto);
line-height: 1.6;
max-width: 860px;
margin: 0 auto;
padding: 40px 24px 80px;
background: #fff;
}
header {
border-bottom: 3px solid var(--verde);
padding-bottom: 20px;
margin-bottom: 32px;
}
header .badge {
display: inline-block;
background: var(--verde);
color: #fff;
font-size: 12px;
font-weight: 700;
letter-spacing: .04em;
padding: 4px 10px;
border-radius: 999px;
margin-bottom: 12px;
}
h1 {
font-size: 32px;
margin: 0 0 6px;
color: #1f2937;
}
header p.subtitulo {
color: var(--gris);
font-size: 14px;
margin: 0;
}
h2 {
font-size: 20px;
color: var(--verde-oscuro);
margin-top: 44px;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--borde);
}
h3 {
font-size: 15px;
margin: 20px 0 6px;
color: #1f2937;
}
p { margin: 10px 0; }
code {
background: var(--fondo-suave);
border: 1px solid var(--borde);
border-radius: 4px;
padding: 2px 6px;
font-size: 13px;
font-family: "SF Mono", Menlo, Consolas, monospace;
color: #b0530f;
}
pre {
background: #1f2937;
color: #d1fae5;
padding: 16px 18px;
border-radius: 8px;
overflow-x: auto;
font-size: 13px;
font-family: "SF Mono", Menlo, Consolas, monospace;
}
pre code { background: none; border: none; color: inherit; padding: 0; }
ul, ol { padding-left: 22px; }
li { margin: 6px 0; }
.caso {
background: var(--fondo-suave);
border: 1px solid var(--borde);
border-left: 4px solid var(--verde);
border-radius: 8px;
padding: 14px 18px;
margin: 14px 0;
}
.caso h3 { margin-top: 0; color: var(--verde-oscuro); }
.caso p { margin: 4px 0 0; font-size: 14px; color: #374151; }
.grid-casos {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
@media (max-width: 640px) {
.grid-casos { grid-template-columns: 1fr; }
body { padding: 24px 16px 60px; }
h1 { font-size: 26px; }
}
.callout {
border-radius: 8px;
padding: 16px 18px;
margin: 18px 0;
font-size: 14px;
}
.callout.bien {
background: #eef6e3;
border: 1px solid #c9e0a3;
}
.callout.limite {
background: #fdf3e7;
border: 1px solid #f0d3a3;
}
.callout strong { display: block; margin-bottom: 6px; }
.lista-check { list-style: none; padding-left: 0; }
.lista-check li { padding-left: 26px; position: relative; }
.lista-check li::before {
content: "✓";
color: var(--verde-oscuro);
font-weight: 700;
position: absolute;
left: 0;
}
.lista-cruz li::before {
content: "✕";
color: #c05621;
}
footer {
margin-top: 60px;
padding-top: 20px;
border-top: 1px solid var(--borde);
font-size: 12px;
color: var(--gris);
text-align: center;
}
@media print {
body { padding: 0; max-width: 100%; }
.caso { break-inside: avoid; }
}
</style>
</head>
<body>
<header>
<span class="badge">DOCUMENTO COMERCIAL</span>
<h1>uMind — Casos de uso</h1>
<p class="subtitulo">Asistente de chat con IA, embebible en cualquier sitio web · Agosto 2026</p>
</header>
<h2>Qué es</h2>
<p>
uMind es un asistente de chat con inteligencia artificial que se instala en cualquier sitio web con una sola línea de código
— igual que Google Analytics o un widget de chat tradicional. A diferencia de un chatbot de respuestas fijas, uMind
<strong>aprende automáticamente el contenido del sitio del cliente</strong> (páginas, servicios, precios, políticas) y
responde preguntas de los visitantes las 24 horas, sin que nadie tenga que programar ni un solo flujo de conversación.
</p>
<p><strong>La instalación es literalmente esto:</strong></p>
<pre><code>&lt;script src="https://admin.u-site.app/widget/umind.js" data-site="SITE_KEY" defer&gt;&lt;/script&gt;</code></pre>
<h2>Cómo funciona (explicación simple para el cliente)</h2>
<ol>
<li>Le pedimos la URL de su sitio.</li>
<li>uMind lo recorre automáticamente y "aprende" el contenido — servicios, productos, preguntas frecuentes, políticas, lo que ya está publicado.</li>
<li>Le entregamos el código de instalación (una línea).</li>
<li>Desde ese momento, cualquier visitante puede hacerle preguntas al asistente por chat, y responde con base en el contenido real del sitio.</li>
<li>Si no sabe algo, no inventa: lo dice honestamente y puede ofrecer que un humano del equipo lo contacte.</li>
</ol>
<h2>Por qué es diferente a un chatbot normal</h2>
<ul>
<li><strong>No hay que escribir guiones de conversación.</strong> El conocimiento sale directo del sitio; si el cliente actualiza su página de precios, basta con volver a "ingestar" el sitio.</li>
<li><strong>No inventa respuestas.</strong> Está instruido para decir "no tengo esa información" en vez de alucinar — algo que preocupa a cualquier negocio serio antes de exponer un bot a sus clientes.</li>
<li><strong>Se instala en minutos, no en semanas.</strong></li>
<li><strong>Funciona en cualquier sitio</strong>, sin importar en qué tecnología esté construido (WordPress, Webflow, HTML plano, una app hecha por U-Site, etc.).</li>
</ul>
<h2>Casos de uso generales</h2>
<div class="caso">
<h3>1. Atención de preguntas frecuentes 24/7</h3>
<p>Horarios, ubicación, formas de pago, políticas de garantía/devolución, sin que el equipo tenga que responder lo mismo veinte veces al día.</p>
</div>
<div class="caso">
<h3>2. Primer filtro de soporte</h3>
<p>Responde lo que puede resolver solo (el 60-80% de las preguntas repetitivas en la mayoría de negocios) y libera al equipo humano para casos que realmente lo requieren.</p>
</div>
<div class="caso">
<h3>3. Guía de productos/servicios</h3>
<p>Un visitante nuevo pregunta "¿qué planes tienen?" o "¿hacen envíos a X ciudad?" y obtiene respuesta inmediata, sin buscar en menús o PDFs.</p>
</div>
<div class="caso">
<h3>4. Reducción de abandono</h3>
<p>Un visitante indeciso que en otro momento se iría del sitio sin comprar/contactar, ahora tiene a quién preguntarle ahí mismo.</p>
</div>
<div class="caso">
<h3>5. Captura de interesados fuera de horario</h3>
<p>El negocio "cierra" pero el asistente sigue respondiendo y puede recoger el interés del visitante para que el equipo humano lo contacte al día siguiente.</p>
</div>
<h2>Casos de uso por tipo de negocio</h2>
<div class="grid-casos">
<div class="caso">
<h3>🍽️ Restaurantes / gastronomía</h3>
<p>Menú, horarios, zona para eventos, reservas, opciones para dietas especiales, ubicación y domicilios.</p>
</div>
<div class="caso">
<h3>🩺 Clínicas / consultorios / salud</h3>
<p>Servicios, especialidades, si atienden una EPS/seguro, cómo agendar cita, requisitos previos. Solo información administrativa ya publicada — sin diagnósticos ni consejos médicos.</p>
</div>
<div class="caso">
<h3>🏠 Inmobiliarias</h3>
<p>Propiedades disponibles por zona/precio, proceso para agendar una visita, requisitos para arrendar/comprar, zonas donde operan.</p>
</div>
<div class="caso">
<h3>🏨 Hoteles / alojamiento</h3>
<p>Disponibilidad general, servicios incluidos, políticas de cancelación, cómo llegar, qué hay cerca.</p>
</div>
<div class="caso">
<h3>🛒 E-commerce / tiendas online</h3>
<p>Políticas de envío y devolución, tallas/variantes, medios de pago, seguimiento de pedido (según lo publicado en el sitio).</p>
</div>
<div class="caso">
<h3>⚖️ Servicios profesionales</h3>
<p>Abogados, contadores, agencias, consultoras: qué servicios prestan, con quién han trabajado, proceso de contratación, tarifas publicadas.</p>
</div>
<div class="caso">
<h3>💻 SaaS / productos digitales</h3>
<p>Incluye los propios productos de U-Site (uLink, uVot, uBox, Habla, UIA): funcionalidades, planes, integraciones, documentación pública — reduce tickets de soporte nivel 1.</p>
</div>
<div class="caso">
<h3>🎓 Educación</h3>
<p>Colegios, institutos, cursos: programas ofrecidos, proceso de admisión/inscripción, costos, fechas importantes, requisitos.</p>
</div>
</div>
<h2>Lo que uMind todavía no hace</h2>
<div class="callout limite">
<strong>Para no prometer de más al vender esto:</strong>
<ul class="lista-check lista-cruz">
<li>No atiende por WhatsApp ni por voz/teléfono todavía — hoy es chat web únicamente. Está en el roadmap.</li>
<li>No ejecuta acciones transaccionales (agendar citas en un calendario real, procesar pagos, crear pedidos) — hoy solo informa con base en el contenido del sitio.</li>
<li>No lee contenido que no esté publicado en el sitio (PDFs privados, Excels internos, etc.) a menos que se cargue aparte.</li>
<li>No reemplaza a un humano en casos sensibles — está instruido para escalar, no para improvisar, en temas delicados (salud, legal, reclamos).</li>
</ul>
</div>
<h2>Argumentos de venta</h2>
<div class="callout bien">
<ul class="lista-check">
<li>Instalación en minutos, sin proyecto largo de desarrollo.</li>
<li>Cero mantenimiento de guiones: si el sitio cambia, se re-entrena con un clic.</li>
<li>Disponible 24/7, sin turnos ni vacaciones.</li>
<li>Reduce carga operativa del equipo de atención al cliente en preguntas repetitivas.</li>
<li>Mejora la percepción de marca: un sitio que responde al instante se siente más profesional.</li>
<li>Escalable por cliente: cada sitio tiene su propio asistente, entrenado solo con su información — nunca se mezcla el conocimiento de un cliente con el de otro.</li>
</ul>
</div>
<h2>Modelo de negocio sugerido</h2>
<p>
<em>(a definir con el equipo comercial)</em> — Un cobro mensual fijo por sitio (incluye hosting del widget y actualizaciones),
con la opción de agregar valor a futuro con paquetes de WhatsApp/voz cuando esas fases del roadmap estén listas. Los costos
operativos por conversación son bajos (fracciones de centavo de dólar por chat de texto), así que el margen sobre una
suscripción mensual es sano incluso en un plan de entrada económico.
</p>
<footer>
U-Site S.A.S BIC · uMind · Documento interno para uso comercial
</footer>
</body>
</html>
-243
View File
@@ -1,243 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>uMind — Guía de onboarding de cliente</title>
<style>
:root {
--verde: #8eb02f;
--verde-oscuro: #5a7a1e;
--texto: #2b2b2b;
--gris: #6b7280;
--borde: #e5e7eb;
--fondo-suave: #f7f8f5;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--texto);
line-height: 1.6;
max-width: 860px;
margin: 0 auto;
padding: 40px 24px 80px;
background: #fff;
}
header {
border-bottom: 3px solid var(--verde);
padding-bottom: 20px;
margin-bottom: 32px;
}
header .badge {
display: inline-block;
background: var(--verde);
color: #fff;
font-size: 12px;
font-weight: 700;
letter-spacing: .04em;
padding: 4px 10px;
border-radius: 999px;
margin-bottom: 12px;
}
h1 { font-size: 32px; margin: 0 0 6px; color: #1f2937; }
header p.subtitulo { color: var(--gris); font-size: 14px; margin: 0; }
h2 {
font-size: 20px;
color: var(--verde-oscuro);
margin-top: 44px;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--borde);
}
h3 { font-size: 15px; margin: 20px 0 6px; color: #1f2937; }
p { margin: 10px 0; }
code {
background: var(--fondo-suave);
border: 1px solid var(--borde);
border-radius: 4px;
padding: 2px 6px;
font-size: 13px;
font-family: "SF Mono", Menlo, Consolas, monospace;
color: #b0530f;
}
pre {
background: #1f2937;
color: #d1fae5;
padding: 16px 18px;
border-radius: 8px;
overflow-x: auto;
font-size: 13px;
font-family: "SF Mono", Menlo, Consolas, monospace;
}
pre code { background: none; border: none; color: inherit; padding: 0; }
ul, ol { padding-left: 22px; }
li { margin: 6px 0; }
.paso {
display: flex;
gap: 14px;
margin: 14px 0;
align-items: flex-start;
}
.paso .num {
flex: 0 0 28px;
height: 28px;
border-radius: 50%;
background: var(--verde);
color: #fff;
font-weight: 700;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
}
.paso .contenido { flex: 1; padding-top: 2px; }
.paso .contenido strong { display: block; margin-bottom: 2px; }
.paso .contenido p { margin: 2px 0 0; font-size: 14px; color: #374151; }
.callout {
border-radius: 8px;
padding: 16px 18px;
margin: 18px 0;
font-size: 14px;
}
.callout.info { background: #eef6e3; border: 1px solid #c9e0a3; }
.callout.alerta { background: #fdf3e7; border: 1px solid #f0d3a3; }
.callout strong { display: block; margin-bottom: 6px; }
.lista-check { list-style: none; padding-left: 0; }
.lista-check li { padding-left: 26px; position: relative; }
.lista-check li::before {
content: "☐";
color: var(--verde-oscuro);
font-weight: 700;
position: absolute;
left: 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 14px 0;
font-size: 13px;
}
th, td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--borde);
}
th { color: var(--gris); font-weight: 600; text-transform: uppercase; font-size: 11px; }
footer {
margin-top: 60px;
padding-top: 20px;
border-top: 1px solid var(--borde);
font-size: 12px;
color: var(--gris);
text-align: center;
}
@media print {
body { padding: 0; max-width: 100%; }
}
</style>
</head>
<body>
<header>
<span class="badge">GUÍA INTERNA</span>
<h1>uMind — Guía de onboarding de cliente</h1>
<p class="subtitulo">Qué configurar y qué pasos seguir para llevar uMind a un cliente nuevo · Agosto 2026</p>
</header>
<h2>1. Configuración única del sistema</h2>
<p>Se hace <strong>una sola vez</strong>, no por cada cliente. Si ya está hecha, sáltate esta sección.</p>
<div class="paso">
<div class="num">1</div>
<div class="contenido">
<strong>Config de embeddings (OpenAI)</strong>
<p>En <code>/app/ai-config</code>, crea una config con provider <strong>OpenAI</strong> y activa el módulo <strong>"uMind — embeddings"</strong>. Necesita su propia API key de OpenAI. La usan <em>todos</em> los clientes para generar los vectores de búsqueda de su base de conocimiento.</p>
</div>
</div>
<div class="paso">
<div class="num">2</div>
<div class="contenido">
<strong>Config de chat (Anthropic recomendado)</strong>
<p>Al menos una config activa de chat. Se asigna a cada tenant al crearlo — puedes reutilizar la misma para todos los clientes, o crear una por cliente si más adelante quieres separar costo/uso por cliente.</p>
</div>
</div>
<h2>2. Información a pedirle al cliente antes de empezar</h2>
<table>
<tr><th>Dato</th><th>Por qué</th></tr>
<tr><td>URL exacta de su sitio</td><td>Es lo que se va a "leer" (crawlear) para entrenar el asistente.</td></tr>
<tr><td>Todos los dominios donde corre el sitio</td><td><strong>Crítico:</strong> con y sin <code>www</code>, y staging si aplica. Si falta uno, el widget se bloquea ahí por seguridad.</td></tr>
<tr><td>Tono/personalidad deseado</td><td>Opcional — formal, cercano, con emojis, etc. Si no lo dan, se usa un tono neutro por defecto.</td></tr>
<tr><td>Mensaje de bienvenida</td><td>Opcional — el saludo que ve el visitante al abrir el chat.</td></tr>
<tr><td>Quién instala el script</td><td>Su desarrollador, o alguien de U-Site si administras el sitio.</td></tr>
</table>
<h2>3. Proceso paso a paso en <code>/app/umind</code></h2>
<div class="paso">
<div class="num">1</div>
<div class="contenido">
<strong>Crear el tenant</strong>
<p>Nombre, dominios permitidos, config de IA de chat, tono, mensaje de bienvenida.</p>
</div>
</div>
<div class="paso">
<div class="num">2</div>
<div class="contenido">
<strong>Ingestar el sitio</strong>
<p>Entra al tenant → pestaña "Base de conocimiento" → pega la URL del sitio → clic en Ingestar. Espera a que el estado pase de "procesando" a "listo" (puede tardar varios minutos según el tamaño del sitio).</p>
</div>
</div>
<div class="paso">
<div class="num">3</div>
<div class="contenido">
<strong>Copiar el código de instalación</strong>
<p>Desde la ficha del tenant, copia el snippet:</p>
<pre><code>&lt;script src="https://admin.u-site.app/widget/umind.js" data-site="SITE_KEY" defer&gt;&lt;/script&gt;</code></pre>
</div>
</div>
<div class="paso">
<div class="num">4</div>
<div class="contenido">
<strong>Entregarlo al cliente</strong>
<p>Que lo peguen justo antes de <code>&lt;/body&gt;</code> en su sitio (o lo pega quien administre el sitio si es un proyecto de U-Site).</p>
</div>
</div>
<div class="paso">
<div class="num">5</div>
<div class="contenido">
<strong>Probar en el sitio real</strong>
<p>Entra al sitio del cliente y hazle un par de preguntas al widget para validar que responde bien antes de darlo por "en producción".</p>
</div>
</div>
<h2>4. Antes de vender a un cliente puntual — revisa esto</h2>
<div class="callout alerta">
<strong>Limitaciones a tener en cuenta:</strong>
<ul class="lista-check">
<li><strong>Sitios 100% React/Vue/Angular sin renderizado en servidor (SPA):</strong> el crawler solo lee el HTML que devuelve el servidor, no ejecuta JavaScript. Si el contenido del sitio se carga todo por JS, la ingesta puede salir casi vacía — revisa el sitio (o pide que lo validen) antes de prometer el servicio.</li>
<li><strong>No hay carga de PDF/Word todavía:</strong> solo se ingesta desde una URL pública. Si el cliente quiere que el asistente conozca un PDF de precios que no está publicado como página web, eso no está soportado hoy.</li>
<li><strong>Sitios muy grandes:</strong> ajustar el número máximo de páginas al ingestar (por defecto 30) si el sitio tiene cientos de páginas.</li>
<li><strong>Sin panel de costos por cliente todavía:</strong> si se factura por consumo real en vez de tarifa plana, hoy no hay forma de ver cuánto gastó cada tenant en la plataforma.</li>
</ul>
</div>
<h2>Checklist rápido</h2>
<div class="callout info">
<ul class="lista-check">
<li>Config global de embeddings (OpenAI) creada — una sola vez</li>
<li>Config global de chat (Anthropic) creada — una sola vez</li>
<li>URL y todos los dominios del cliente confirmados</li>
<li>Tenant creado en /app/umind</li>
<li>Sitio ingestado y en estado "listo"</li>
<li>Snippet copiado y entregado</li>
<li>Widget probado en el sitio real</li>
</ul>
</div>
<footer>
U-Site S.A.S BIC · uMind · Guía interna de onboarding
</footer>
</body>
</html>
+5 -40
View File
@@ -18,9 +18,6 @@ TOKEN=""
API_URL="" API_URL=""
INTERVAL=30 INTERVAL=30
INSTALL_DIR="" INSTALL_DIR=""
CPU_THRESHOLD=90
RENICE_ENABLED=true
RENICE_CONSECUTIVE=2
for arg in "$@"; do for arg in "$@"; do
case $arg in case $arg in
@@ -28,9 +25,6 @@ for arg in "$@"; do
--url=*) API_URL="${arg#*=}" ;; --url=*) API_URL="${arg#*=}" ;;
--interval=*) INTERVAL="${arg#*=}" ;; --interval=*) INTERVAL="${arg#*=}" ;;
--dir=*) INSTALL_DIR="${arg#*=}" ;; --dir=*) INSTALL_DIR="${arg#*=}" ;;
--cpu-threshold=*) CPU_THRESHOLD="${arg#*=}" ;;
--renice=*) RENICE_ENABLED="${arg#*=}" ;;
--renice-consecutive=*) RENICE_CONSECUTIVE="${arg#*=}" ;;
esac esac
done done
@@ -80,38 +74,17 @@ info "Directorio: ${INSTALL_DIR}"
info "Intervalo: ${INTERVAL}s" info "Intervalo: ${INTERVAL}s"
# ── Instalar binario ────────────────────────────────────────────────────────── # ── Instalar binario ──────────────────────────────────────────────────────────
mkdir -p "$INSTALL_DIR" || error "No se pudo crear directorio ${INSTALL_DIR} (¿permisos?)" mkdir -p "$INSTALL_DIR"
# Verificar espacio disponible (necesita al menos 20 MB)
AVAIL_KB=$(df -k "$INSTALL_DIR" 2>/dev/null | awk 'NR==2 {print $4}')
if [[ -n "$AVAIL_KB" && "$AVAIL_KB" -lt 20480 ]]; then
error "Espacio insuficiente en ${INSTALL_DIR}: solo ${AVAIL_KB} KB disponibles (se necesitan ≥20 MB)"
fi
info "Descargando agente desde ${DOWNLOAD_URL}..." info "Descargando agente desde ${DOWNLOAD_URL}..."
DEST="${INSTALL_DIR}/usite-agent"
TMP_DEST="/tmp/usite-agent-download-$$"
if command -v curl &>/dev/null; then if command -v curl &>/dev/null; then
curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DEST" 2>/tmp/curl_err || { curl -fsSL "$DOWNLOAD_URL" -o "${INSTALL_DIR}/usite-agent" || error "No se pudo descargar"
CURL_ERR=$(cat /tmp/curl_err 2>/dev/null)
rm -f "$TMP_DEST"
error "No se pudo descargar: ${CURL_ERR}"
}
elif command -v wget &>/dev/null; then elif command -v wget &>/dev/null; then
wget -q "$DOWNLOAD_URL" -O "$TMP_DEST" || { rm -f "$TMP_DEST"; error "No se pudo descargar con wget"; } wget -q "$DOWNLOAD_URL" -O "${INSTALL_DIR}/usite-agent" || error "No se pudo descargar"
else else
error "Se requiere curl o wget" error "Se requiere curl o wget"
fi fi
chmod +x "${INSTALL_DIR}/usite-agent"
if [[ ! -s "$TMP_DEST" ]]; then ok "Binario descargado"
rm -f "$TMP_DEST"
error "El archivo descargado está vacío"
fi
mv "$TMP_DEST" "$DEST" || { cp "$TMP_DEST" "$DEST" && rm -f "$TMP_DEST"; } || error "No se pudo mover el binario a ${DEST}"
chmod +x "$DEST"
ok "Binario descargado ($(du -sh "$DEST" | cut -f1))"
# ── Configuración ───────────────────────────────────────────────────────────── # ── Configuración ─────────────────────────────────────────────────────────────
cat > "${INSTALL_DIR}/agent.yml" <<EOF cat > "${INSTALL_DIR}/agent.yml" <<EOF
@@ -119,9 +92,6 @@ api_url: ${API_URL}
token: ${TOKEN} token: ${TOKEN}
interval: ${INTERVAL} interval: ${INTERVAL}
debug: false debug: false
cpu_threshold: ${CPU_THRESHOLD}
renice_enabled: ${RENICE_ENABLED}
renice_consecutive: ${RENICE_CONSECUTIVE}
EOF EOF
ok "Configuración creada en ${INSTALL_DIR}/agent.yml" ok "Configuración creada en ${INSTALL_DIR}/agent.yml"
@@ -151,13 +121,8 @@ WantedBy=multi-user.target
EOF EOF
systemctl daemon-reload systemctl daemon-reload
systemctl enable usite-agent systemctl enable usite-agent
if systemctl is-active --quiet usite-agent; then
systemctl restart usite-agent
ok "Servicio systemd 'usite-agent' reiniciado con nueva configuración"
else
systemctl start usite-agent systemctl start usite-agent
ok "Servicio systemd 'usite-agent' instalado y activo" ok "Servicio systemd 'usite-agent' instalado y activo"
fi
STARTED=1 STARTED=1
# 2) openrc (Alpine, algunos VPS) # 2) openrc (Alpine, algunos VPS)
+45 -184
View File
@@ -8,10 +8,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"os/exec"
"runtime" "runtime"
"sort"
"strings"
"time" "time"
"github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/cpu"
@@ -19,21 +16,16 @@ import (
"github.com/shirou/gopsutil/v3/host" "github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/load" "github.com/shirou/gopsutil/v3/load"
"github.com/shirou/gopsutil/v3/mem" "github.com/shirou/gopsutil/v3/mem"
pnet "github.com/shirou/gopsutil/v3/net"
"github.com/shirou/gopsutil/v3/process"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// ── Config ──────────────────────────────────────────────────────────────────── // ── Config ────────────────────────────────────────────────────────────────────
type Config struct { type Config struct {
APIURL string `yaml:"api_url"` APIURL string `yaml:"api_url"` // https://admin.u-site.app
Token string `yaml:"token"` Token string `yaml:"token"` // token generado desde el panel
Interval int `yaml:"interval"` Interval int `yaml:"interval"` // segundos entre reportes (default: 30)
Debug bool `yaml:"debug"` Debug bool `yaml:"debug"`
CPUThreshold float64 `yaml:"cpu_threshold"` // % para activar renice (default 90)
ReniceEnabled bool `yaml:"renice_enabled"` // activar renice automático
ReniceConsecutive int `yaml:"renice_consecutive"` // reportes consecutivos requeridos (default 2)
} }
func loadConfig(path string) (*Config, error) { func loadConfig(path string) (*Config, error) {
@@ -48,12 +40,6 @@ func loadConfig(path string) (*Config, error) {
if cfg.Interval <= 0 { if cfg.Interval <= 0 {
cfg.Interval = 30 cfg.Interval = 30
} }
if cfg.CPUThreshold <= 0 {
cfg.CPUThreshold = 90
}
if cfg.ReniceConsecutive <= 0 {
cfg.ReniceConsecutive = 2
}
return &cfg, nil return &cfg, nil
} }
@@ -66,13 +52,9 @@ type Metricas struct {
Uptime uint64 `json:"uptime_seg"` Uptime uint64 `json:"uptime_seg"`
UptimeStr string `json:"uptime"` UptimeStr string `json:"uptime"`
RAM RAMInfo `json:"ram"` RAM RAMInfo `json:"ram"`
Swap SwapInfo `json:"swap"`
CPU CPUInfo `json:"cpu"` CPU CPUInfo `json:"cpu"`
Discos []DiscoInfo `json:"discos"` Discos []DiscoInfo `json:"discos"`
LoadAvg LoadInfo `json:"load_avg"` LoadAvg LoadInfo `json:"load_avg"`
Red []RedInfo `json:"red"`
TCPConns int `json:"tcp_conns"`
TopProcs []ProcesoInfo `json:"top_procs"`
ReportadoEn string `json:"reportado_en"` ReportadoEn string `json:"reportado_en"`
} }
@@ -83,12 +65,6 @@ type RAMInfo struct {
Porcentaje float64 `json:"porcentaje"` Porcentaje float64 `json:"porcentaje"`
} }
type SwapInfo struct {
TotalGB float64 `json:"total_gb"`
UsadoGB float64 `json:"usado_gb"`
Porcentaje float64 `json:"porcentaje"`
}
type CPUInfo struct { type CPUInfo struct {
Nucleos int `json:"nucleos"` Nucleos int `json:"nucleos"`
Porcentaje float64 `json:"porcentaje"` Porcentaje float64 `json:"porcentaje"`
@@ -108,21 +84,6 @@ type LoadInfo struct {
Load15 float64 `json:"load15"` Load15 float64 `json:"load15"`
} }
type RedInfo struct {
Interface string `json:"interface"`
BytesEnvMB float64 `json:"bytes_env_mb"`
BytesRecMB float64 `json:"bytes_rec_mb"`
PktEnv uint64 `json:"pkt_env"`
PktRec uint64 `json:"pkt_rec"`
}
type ProcesoInfo struct {
PID int32 `json:"pid"`
Nombre string `json:"nombre"`
CPU float64 `json:"cpu"`
RAMMB float64 `json:"ram_mb"`
}
func round2(v float64) float64 { func round2(v float64) float64 {
return float64(int(v*100)) / 100 return float64(int(v*100)) / 100
} }
@@ -149,11 +110,11 @@ func collectMetrics() (*Metricas, error) {
m.Hostname = hn m.Hostname = hn
} }
// Uptime + OS // Uptime
if info, err := host.Info(); err == nil { if info, err := host.Info(); err == nil {
m.Uptime = info.Uptime m.Uptime = info.Uptime
m.UptimeStr = uptimeStr(info.Uptime) m.UptimeStr = uptimeStr(info.Uptime)
if info.Platform != "" { if info.OS != "" {
m.OS = info.Platform + " " + info.PlatformVersion m.OS = info.Platform + " " + info.PlatformVersion
} }
} }
@@ -169,132 +130,61 @@ func collectMetrics() (*Metricas, error) {
} }
} }
// Swap
if sv, err := mem.SwapMemory(); err == nil && sv.Total > 0 {
gb := 1024.0 * 1024 * 1024
m.Swap = SwapInfo{
TotalGB: round2(float64(sv.Total) / gb),
UsadoGB: round2(float64(sv.Used) / gb),
Porcentaje: round2(sv.UsedPercent),
}
}
// CPU // CPU
m.CPU.Nucleos = runtime.NumCPU() m.CPU.Nucleos = runtime.NumCPU()
if pcts, err := cpu.Percent(1*time.Second, false); err == nil && len(pcts) > 0 { if pcts, err := cpu.Percent(1*time.Second, false); err == nil && len(pcts) > 0 {
m.CPU.Porcentaje = round2(pcts[0]) m.CPU.Porcentaje = round2(pcts[0])
} }
// Discos — incluir todo filesystem con espacio real (>100 MB), sin duplicar mountpoints // Discos: todas las particiones físicas/reales
{ if parts, err := disk.Partitions(false); err == nil {
gb := 1024.0 * 1024 * 1024 gb := 1024.0 * 1024 * 1024
skipFS := map[string]bool{ skipFSTypes := map[string]bool{
"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "proc": true, "sysfs": true, "tmpfs": true, "devtmpfs": true,
"cgroup": true, "cgroup2": true, "hugetlbfs": true, "mqueue": true, "devpts": true, "cgroup": true, "cgroup2": true, "overlay": true,
"pstore": true, "securityfs": true, "debugfs": true, "tracefs": true, "hugetlbfs": true, "mqueue": true, "pstore": true, "securityfs": true,
"autofs": true, "fusectl": true, "configfs": true, "efivarfs": true, "debugfs": true, "tracefs": true, "autofs": true, "fusectl": true,
"bpf": true, "rpc_pipefs": true, "nsfs": true, "ramfs": true, "configfs": true, "efivarfs": true, "bpf": true, "rpc_pipefs": true,
"squashfs": true, // snap packages: loop devices de solo lectura, siempre 100% "squashfs": true, "ramfs": true, "nsfs": true, "none": true,
}
seen := map[string]bool{}
parts, _ := disk.Partitions(true)
// Asegurar que siempre se incluye la raíz
type partEntry struct{ mountpoint, fstype string }
entries := []partEntry{{"/", ""}}
if runtime.GOOS == "windows" {
entries = []partEntry{{"C:\\", ""}}
} }
for _, p := range parts { for _, p := range parts {
entries = append(entries, partEntry{p.Mountpoint, p.Fstype}) if skipFSTypes[p.Fstype] {
}
for _, e := range entries {
if seen[e.mountpoint] || skipFS[e.fstype] {
continue continue
} }
if strings.HasPrefix(e.mountpoint, "/snap/") { if d, err := disk.Usage(p.Mountpoint); err == nil {
continue
}
d, err := disk.Usage(e.mountpoint)
if err != nil || d.Total == 0 {
continue
}
totalGB := float64(d.Total) / gb
if totalGB < 0.1 { // ignorar < 100 MB (tmpfs pequeños, etc.)
continue
}
seen[e.mountpoint] = true
m.Discos = append(m.Discos, DiscoInfo{ m.Discos = append(m.Discos, DiscoInfo{
TotalGB: round2(totalGB), TotalGB: round2(float64(d.Total) / gb),
UsadoGB: round2(float64(d.Used) / gb), UsadoGB: round2(float64(d.Used) / gb),
LibreGB: round2(float64(d.Free) / gb), LibreGB: round2(float64(d.Free) / gb),
Porcentaje: round2(d.UsedPercent), Porcentaje: round2(d.UsedPercent),
Ruta: e.mountpoint, Ruta: p.Mountpoint,
}) })
} }
} }
}
// Fallback: si no se encontraron particiones, usar raíz
if len(m.Discos) == 0 {
root := "/"
if runtime.GOOS == "windows" {
root = "C:\\"
}
if d, err := disk.Usage(root); err == nil {
gb := 1024.0 * 1024 * 1024
m.Discos = []DiscoInfo{{
TotalGB: round2(float64(d.Total) / gb),
UsadoGB: round2(float64(d.Used) / gb),
LibreGB: round2(float64(d.Free) / gb),
Porcentaje: round2(d.UsedPercent),
Ruta: root,
}}
}
}
// Load average // Load average (Linux/macOS)
if la, err := load.Avg(); err == nil { if la, err := load.Avg(); err == nil {
m.LoadAvg = LoadInfo{Load1: round2(la.Load1), Load5: round2(la.Load5), Load15: round2(la.Load15)} m.LoadAvg = LoadInfo{Load1: round2(la.Load1), Load5: round2(la.Load5), Load15: round2(la.Load15)}
} }
// Red (interfaces físicas, excluir loopback)
if counters, err := pnet.IOCounters(true); err == nil {
mb := 1024.0 * 1024
for _, c := range counters {
if c.Name == "lo" || c.Name == "lo0" {
continue
}
m.Red = append(m.Red, RedInfo{
Interface: c.Name,
BytesEnvMB: round2(float64(c.BytesSent) / mb),
BytesRecMB: round2(float64(c.BytesRecv) / mb),
PktEnv: c.PacketsSent,
PktRec: c.PacketsRecv,
})
}
}
// TCP conexiones activas
if conns, err := pnet.Connections("tcp"); err == nil {
m.TCPConns = len(conns)
}
// Top 10 procesos: filtrar por RAM primero (barato), luego leer CPU solo en esos 10
if procs, err := process.Processes(); err == nil {
type pd struct {
proc *process.Process
name string
ramMB float64
}
var lista []pd
for _, p := range procs {
name, _ := p.Name()
if name == "" {
continue
}
ramMB := 0.0
if mi, err := p.MemoryInfo(); err == nil && mi != nil {
ramMB = round2(float64(mi.RSS) / (1024 * 1024))
}
if ramMB < 1 {
continue
}
lista = append(lista, pd{p, name, ramMB})
}
sort.Slice(lista, func(i, j int) bool {
return lista[i].ramMB > lista[j].ramMB
})
limit := 10
if len(lista) < limit {
limit = len(lista)
}
for _, d := range lista[:limit] {
cpuPct, _ := d.proc.CPUPercent() // solo 10 lecturas, no cientos
m.TopProcs = append(m.TopProcs, ProcesoInfo{d.proc.Pid, d.name, round2(cpuPct), d.ramMB})
}
}
return m, nil return m, nil
} }
@@ -303,10 +193,9 @@ func collectMetrics() (*Metricas, error) {
type HeartbeatRequest struct { type HeartbeatRequest struct {
Token string `json:"token"` Token string `json:"token"`
Metricas string `json:"metricas"` Metricas string `json:"metricas"`
ReniceAction string `json:"renice_action,omitempty"` // descripción si se reniceó algo
} }
func sendHeartbeat(cfg *Config, metricas *Metricas, reniceAction string) error { func sendHeartbeat(cfg *Config, metricas *Metricas) error {
metJSON, err := json.Marshal(metricas) metJSON, err := json.Marshal(metricas)
if err != nil { if err != nil {
return err return err
@@ -315,7 +204,6 @@ func sendHeartbeat(cfg *Config, metricas *Metricas, reniceAction string) error {
payload := HeartbeatRequest{ payload := HeartbeatRequest{
Token: cfg.Token, Token: cfg.Token,
Metricas: string(metJSON), Metricas: string(metJSON),
ReniceAction: reniceAction,
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
@@ -350,12 +238,14 @@ func main() {
cfg, err := loadConfig(*configPath) cfg, err := loadConfig(*configPath)
if err != nil { if err != nil {
// Si no hay config pero se pasaron flags, usarlos directamente
if *apiURL == "" || *token == "" { if *apiURL == "" || *token == "" {
log.Fatalf("Error cargando config: %v\nUso: usite-agent --api-url=https://... --token=...", err) log.Fatalf("Error cargando config: %v\nUso: usite-agent --api-url=https://... --token=...", err)
} }
cfg = &Config{APIURL: *apiURL, Token: *token, Interval: 30} cfg = &Config{APIURL: *apiURL, Token: *token, Interval: 30}
} }
// Flags sobreescriben config
if *apiURL != "" { if *apiURL != "" {
cfg.APIURL = *apiURL cfg.APIURL = *apiURL
} }
@@ -367,14 +257,12 @@ func main() {
log.Fatal("api_url y token son requeridos") log.Fatal("api_url y token son requeridos")
} }
log.Printf("🚀 usite-agent iniciado | API: %s | Intervalo: %ds | Renice: %v (umbral %.0f%%, %d reportes)", log.Printf("🚀 usite-agent iniciado | API: %s | Intervalo: %ds", cfg.APIURL, cfg.Interval)
cfg.APIURL, cfg.Interval, cfg.ReniceEnabled, cfg.CPUThreshold, cfg.ReniceConsecutive)
ticker := time.NewTicker(time.Duration(cfg.Interval) * time.Second) ticker := time.NewTicker(time.Duration(cfg.Interval) * time.Second)
defer ticker.Stop() defer ticker.Stop()
consecAlto := 0 // contador de reportes consecutivos con CPU alta // Enviar inmediatamente al iniciar
reportar := func() { reportar := func() {
m, err := collectMetrics() m, err := collectMetrics()
if err != nil { if err != nil {
@@ -385,38 +273,11 @@ func main() {
b, _ := json.MarshalIndent(m, "", " ") b, _ := json.MarshalIndent(m, "", " ")
log.Printf("📊 Métricas:\n%s", string(b)) log.Printf("📊 Métricas:\n%s", string(b))
} }
if err := sendHeartbeat(cfg, m); err != nil {
// ── Lógica de renice ──────────────────────────────────────────────────
var reniceAction string
if cfg.ReniceEnabled && m.CPU.Porcentaje >= cfg.CPUThreshold {
consecAlto++
log.Printf("⚠️ CPU alta: %.1f%% (reporte %d/%d)", m.CPU.Porcentaje, consecAlto, cfg.ReniceConsecutive)
if consecAlto >= cfg.ReniceConsecutive && len(m.TopProcs) > 0 {
// Ordenar top_procs por CPU para encontrar el culpable real
procs := make([]ProcesoInfo, len(m.TopProcs))
copy(procs, m.TopProcs)
sort.Slice(procs, func(i, j int) bool { return procs[i].CPU > procs[j].CPU })
culpable := procs[0]
if culpable.CPU > 0 && culpable.PID > 0 {
cmd := exec.Command("renice", "-n", "19", "-p", fmt.Sprintf("%d", culpable.PID))
if rerr := cmd.Run(); rerr != nil {
log.Printf("❌ renice falló para PID %d (%s): %v", culpable.PID, culpable.Nombre, rerr)
} else {
reniceAction = fmt.Sprintf("renice +19 aplicado a %s (PID %d, CPU %.1f%%)", culpable.Nombre, culpable.PID, culpable.CPU)
log.Printf("🔧 %s", reniceAction)
consecAlto = 0 // reiniciar tras actuar
}
}
}
} else {
consecAlto = 0
}
if err := sendHeartbeat(cfg, m, reniceAction); err != nil {
log.Printf("⚠️ Error enviando heartbeat: %v", err) log.Printf("⚠️ Error enviando heartbeat: %v", err)
} else { } else {
log.Printf("✅ Heartbeat | RAM: %.1f%% | CPU: %.1f%% | TCP: %d | Procs: %d", log.Printf("✅ Heartbeat enviado | RAM: %.1f%% | CPU: %.1f%% | Discos: %d",
m.RAM.Porcentaje, m.CPU.Porcentaje, m.TCPConns, len(m.TopProcs)) m.RAM.Porcentaje, m.CPU.Porcentaje, len(m.Discos))
} }
} }
BIN
View File
Binary file not shown.
-8
View File
@@ -51,14 +51,6 @@ 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
+1 -9
View File
@@ -55,14 +55,6 @@ 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
@@ -72,7 +64,7 @@ jwt:
expire: 31536000 expire: 31536000
template: template:
path: "resources/views" path: "resources/view"
extension: ".html" extension: ".html"
log: log:
-7
View File
@@ -28,7 +28,6 @@ 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
@@ -41,7 +40,6 @@ 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
@@ -96,11 +94,6 @@ 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{
ByteRange: true,
})
cfg.Server.Static("/", filepath.Join(cfg.Server.Path, cfg.Server.PublicPath), fiber.Static{ cfg.Server.Static("/", filepath.Join(cfg.Server.Path, cfg.Server.PublicPath), fiber.Static{
Compress: true, Compress: true,
ByteRange: true, ByteRange: true,
-12
View File
@@ -1,12 +0,0 @@
package config
// OAuthConfig trae las credenciales de las apps OAuth para conectar cuentas
// de correo (uMind: enviar/leer correo en nombre de un tenant). Sin
// env-default, igual que los secretos JWT — se cargan por variable de
// entorno, no quedan escritos en config.yml.
type OAuthConfig struct {
GoogleClientID string `mapstructure:"GOOGLE_OAUTH_CLIENT_ID" yaml:"google_client_id" env:"GOOGLE_OAUTH_CLIENT_ID"`
GoogleClientSecret string `mapstructure:"GOOGLE_OAUTH_CLIENT_SECRET" yaml:"google_client_secret" env:"GOOGLE_OAUTH_CLIENT_SECRET"`
MSClientID string `mapstructure:"MS_OAUTH_CLIENT_ID" yaml:"ms_client_id" env:"MS_OAUTH_CLIENT_ID"`
MSClientSecret string `mapstructure:"MS_OAUTH_CLIENT_SECRET" yaml:"ms_client_secret" env:"MS_OAUTH_CLIENT_SECRET"`
}
-64
View File
@@ -1,64 +0,0 @@
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
}
+3 -3
View File
@@ -11,11 +11,11 @@ type SessionConfig struct {
*session.Session *session.Session
} }
// Setup configura el almacén de sesiones en memoria sin vencimiento práctico. // Setup configura el almacén de sesiones en memoria con expiración de 1 año.
func (s *SessionConfig) Setup(db DatabaseDriver) error { func (s *SessionConfig) Setup(db DatabaseDriver) error {
s.Session = session.New(session.Config{ s.Session = session.New(session.Config{
Expiration: 90 * 365 * 24 * time.Hour, Expiration: 365 * 24 * time.Hour,
}) })
log.Println("[SESSION] Sesiones en memoria (sin vencimiento práctico)") log.Println("[SESSION] Sesiones en memoria (expiran en 1 año o al reiniciar)")
return nil return nil
} }
+7 -12
View File
@@ -11,24 +11,20 @@ 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"`
// Los secretos JWT firman las cookies de sesión: si se filtran, cualquiera AppJwtSecret string `mapstructure:"APP_JWT_SECRET" yaml:"app_jwt_secret"`
// puede fabricarse una sesión de administrador válida. El tag env: permite ApiJwtSecret string `mapstructure:"API_JWT_SECRET" yaml:"api_jwt_secret"`
// 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)
claims := token.Claims.(jwt.MapClaims) claims := token.Claims.(jwt.MapClaims)
claims["id"] = userID claims["id"] = userID
const ninetyYears = int64(90 * 365 * 24 * 3600)
if len(expire) > 0 { if len(expire) > 0 {
t.Expire = expire[0] t.Expire = expire[0]
} else { } else {
t.Expire = ninetyYears t.Expire = 31536000 // 1 año por defecto si no se configura
} }
expiresIn := time.Now().Add(time.Duration(t.Expire) * time.Second).Unix() expiresIn := time.Now().Add(time.Duration(t.Expire) * time.Second).Unix()
claims["exp"] = expiresIn claims["exp"] = expiresIn
@@ -41,7 +37,6 @@ func (t *Token) CreateToken(c *fiber.Ctx, userID uint, secret string, expire ...
c.Cookie(&fiber.Cookie{ c.Cookie(&fiber.Cookie{
Name: "Verify-Rest-Token", Name: "Verify-Rest-Token",
Value: tokenHash, Value: tokenHash,
MaxAge: int(ninetyYears),
Secure: false, Secure: false,
HTTPOnly: true, HTTPOnly: true,
}) })
@@ -50,7 +45,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")
@@ -77,12 +72,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)
-36
View File
@@ -1,36 +0,0 @@
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")
}
-505
View File
@@ -1,505 +0,0 @@
<title>Contrato API v1</title>
<style>
/* ── Tokens ───────────────────────────────────────────────────────────────
Paleta anclada al verde de marca de U-Site (#8eb02f) que ya usa el panel,
el widget y los correos. Neutros con sesgo oliva para que el acento no
flote sobre un gris ajeno. Los colores semánticos (crítico / atención /
confirmado) son un juego aparte del acento, a propósito. */
:root {
--ground: #f6f6f2;
--surface: #ffffff;
--surface-sunk: #f0f1ea;
--line: #e0e1d6;
--line-strong: #c9cbba;
--text: #1c1e18;
--text-soft: #5a5d51;
--text-faint: #86897a;
--accent: #63801c;
--accent-soft: #eef3dd;
--crit: #a32a1e;
--crit-soft: #fbe9e6;
--warn: #8a5a06;
--warn-soft: #fbf0d9;
--ok: #2f6b3f;
--ok-soft: #e6f1e6;
--code-bg: #1a1c16;
--code-text: #e8eadd;
--code-dim: #8f947f;
--shadow: 0 1px 2px rgba(28,30,24,.06), 0 8px 24px -12px rgba(28,30,24,.14);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #14150f;
--surface: #1c1e17;
--surface-sunk: #24261d;
--line: #32352a;
--line-strong: #474b3c;
--text: #e9ebdf;
--text-soft: #b0b4a2;
--text-faint: #82866f;
--accent: #a8c94f;
--accent-soft: #2a3118;
--crit: #f08a7c;
--crit-soft: #38201c;
--warn: #e5b45c;
--warn-soft: #362a13;
--ok: #7fc08d;
--ok-soft: #1d2f22;
--code-bg: #0e0f0a;
--code-text: #e8eadd;
--code-dim: #7b8069;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 10px 30px -14px rgba(0,0,0,.7);
}
}
:root[data-theme="dark"] {
--ground: #14150f;
--surface: #1c1e17;
--surface-sunk: #24261d;
--line: #32352a;
--line-strong: #474b3c;
--text: #e9ebdf;
--text-soft: #b0b4a2;
--text-faint: #82866f;
--accent: #a8c94f;
--accent-soft: #2a3118;
--crit: #f08a7c;
--crit-soft: #38201c;
--warn: #e5b45c;
--warn-soft: #362a13;
--ok: #7fc08d;
--ok-soft: #1d2f22;
--code-bg: #0e0f0a;
--code-text: #e8eadd;
--code-dim: #7b8069;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 10px 30px -14px rgba(0,0,0,.7);
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--ground);
color: var(--text);
font-family: ui-sans-serif, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 60rem; margin: 0 auto; padding: 3rem 1.5rem 6rem; }
/* ── Encabezado ─────────────────────────────────────────────────────────── */
.eyebrow {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .6875rem; letter-spacing: .14em; text-transform: uppercase;
color: var(--accent); margin: 0 0 .75rem;
}
h1 {
font-size: clamp(1.9rem, 1.3rem + 2.2vw, 2.75rem);
line-height: 1.1; letter-spacing: -.022em; font-weight: 660;
margin: 0 0 .85rem; text-wrap: balance;
}
.standfirst {
font-size: 1.0625rem; color: var(--text-soft);
max-width: 46rem; margin: 0 0 2rem;
}
.standfirst strong { color: var(--text); font-weight: 620; }
/* ── Resumen ────────────────────────────────────────────────────────────── */
.tally { display: flex; flex-wrap: wrap; gap: .625rem; margin-bottom: 1rem; }
.tally-item {
display: flex; align-items: baseline; gap: .5rem;
background: var(--surface); border: 1px solid var(--line);
border-radius: .5rem; padding: .625rem .875rem; box-shadow: var(--shadow);
}
.tally-n {
font-size: 1.375rem; font-weight: 680; line-height: 1;
font-variant-numeric: tabular-nums;
}
.tally-l { font-size: .8125rem; color: var(--text-soft); }
.tally-item.is-crit { border-color: var(--crit); }
.tally-item.is-crit .tally-n { color: var(--crit); }
.tally-item.is-warn .tally-n { color: var(--warn); }
.tally-item.is-ok .tally-n { color: var(--ok); }
.meta {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .75rem; color: var(--text-faint);
border-top: 1px solid var(--line); padding-top: 1rem; margin-bottom: 2.75rem;
}
.meta code { background: none; padding: 0; color: var(--text-soft); }
/* ── Secciones numeradas ────────────────────────────────────────────────
La numeración no es decorativa: el otro equipo mandó las preguntas
numeradas del 1 al 10 y así se responden en el mismo orden. */
.q {
display: grid; grid-template-columns: 3.25rem 1fr; gap: 0 1.25rem;
padding: 1.75rem 0; border-top: 1px solid var(--line);
}
.q-num {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .875rem; font-weight: 600; color: var(--text-faint);
font-variant-numeric: tabular-nums; padding-top: .3rem;
}
.q-body { min-width: 0; }
.q h2 {
font-size: 1.1875rem; line-height: 1.3; letter-spacing: -.012em;
font-weight: 640; margin: 0 0 .6rem; text-wrap: balance;
}
.q p { margin: 0 0 .85rem; }
.q p:last-child { margin-bottom: 0; }
.q ul { margin: 0 0 .85rem; padding-left: 1.15rem; }
.q li { margin-bottom: .3rem; }
/* Barra de severidad a la izquierda del número */
.q.sev-crit .q-num { color: var(--crit); }
.q.sev-warn .q-num { color: var(--warn); }
.q.sev-ok .q-num { color: var(--ok); }
.chip {
display: inline-block; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .6875rem; letter-spacing: .06em; text-transform: uppercase;
padding: .2rem .5rem; border-radius: .3125rem; margin-bottom: .55rem;
font-weight: 600;
}
.chip-crit { background: var(--crit-soft); color: var(--crit); }
.chip-warn { background: var(--warn-soft); color: var(--warn); }
.chip-ok { background: var(--ok-soft); color: var(--ok); }
.chip-info { background: var(--surface-sunk); color: var(--text-soft); }
/* ── Código ─────────────────────────────────────────────────────────────── */
pre {
background: var(--code-bg); color: var(--code-text);
border-radius: .5rem; padding: .875rem 1rem; margin: 0 0 .85rem;
overflow-x: auto; font-size: .8125rem; line-height: 1.55;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
pre .c { color: var(--code-dim); }
pre .hl { color: #d9e88a; }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .875em; background: var(--surface-sunk);
padding: .1rem .3rem; border-radius: .25rem;
}
.cite {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .75rem; color: var(--text-faint); margin: -.35rem 0 .85rem;
}
.note {
border-left: 2px solid var(--accent); background: var(--accent-soft);
padding: .75rem .9rem; border-radius: 0 .375rem .375rem 0;
margin: 0 0 .85rem; font-size: .9375rem;
}
.note.is-crit { border-left-color: var(--crit); background: var(--crit-soft); }
.note p { margin: 0; }
.note p + p { margin-top: .5rem; }
/* ── Tablas ─────────────────────────────────────────────────────────────── */
.scroll { overflow-x: auto; margin: 0 0 .85rem; }
table { border-collapse: collapse; width: 100%; font-size: .875rem; min-width: 32rem; }
th, td { text-align: left; padding: .5rem .7rem; border-bottom: 1px solid var(--line); vertical-align: top; }
th {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .6875rem; letter-spacing: .07em; text-transform: uppercase;
color: var(--text-faint); font-weight: 600;
border-bottom-color: var(--line-strong);
}
td code { font-size: .8125rem; }
/* ── Cierre ─────────────────────────────────────────────────────────────── */
.todo {
margin-top: 3rem; background: var(--surface); border: 1px solid var(--line);
border-radius: .625rem; padding: 1.5rem 1.75rem; box-shadow: var(--shadow);
}
.todo h2 { font-size: 1.125rem; font-weight: 640; margin: 0 0 .35rem; letter-spacing: -.01em; }
.todo > p { color: var(--text-soft); font-size: .9375rem; margin: 0 0 1.1rem; }
.todo ol { margin: 0; padding-left: 1.15rem; }
.todo li { margin-bottom: .7rem; }
.todo li:last-child { margin-bottom: 0; }
.todo li strong { font-weight: 620; }
a { color: var(--accent); text-underline-offset: 2px; }
a:focus-visible, [tabindex]:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px; border-radius: .2rem;
}
@media (max-width: 34rem) {
.q { grid-template-columns: 1fr; gap: 0; }
.q-num { padding-top: 0; margin-bottom: .35rem; }
}
</style>
<div class="wrap">
<p class="eyebrow">Referencia de integración · admin.u-site.app</p>
<h1>Contrato real de la API v1</h1>
<p class="standfirst">
Las 10 preguntas del equipo integrador, respondidas <strong>leyendo el código del
servidor</strong> en vez de inferirlas desde el cliente. Cada respuesta cita archivo y
línea para que se pueda auditar. Una es crítica y explica por qué la integración
probablemente nunca autenticó.
</p>
<div class="tally">
<div class="tally-item is-crit"><span class="tally-n">1</span><span class="tally-l">crítica</span></div>
<div class="tally-item is-warn"><span class="tally-n">2</span><span class="tally-l">requieren atención</span></div>
<div class="tally-item is-ok"><span class="tally-n">7</span><span class="tally-l">contrato confirmado</span></div>
<div class="tally-item"><span class="tally-n">4</span><span class="tally-l">arreglos de nuestro lado</span></div>
</div>
<p class="meta">
Base de todos los endpoints: <code>https://admin.u-site.app/api/v1/…</code><br>
Montaje: <code>rest/routes/routes.go:10</code> monta <code>/api</code> · <code>rest/routes/api.go:38</code> agrega <code>v1</code>
</p>
<!-- 1 -->
<section class="q sev-ok">
<div class="q-num">01</div>
<div class="q-body">
<span class="chip chip-ok">Su lectura es correcta</span>
<h2><code>expires_in</code> es un timestamp Unix absoluto</h2>
<p>El campo se reusa: entra como duración en segundos y sale como timestamp.</p>
<pre><span class="c">// config/token.go:26-49</span>
t.Expire = ninetyYears <span class="c">// duración, en segundos</span>
expiresIn := time.Now().Add(
time.Duration(t.Expire)*time.Second).Unix()
claims["exp"] = expiresIn
t.Expire = <span class="hl">expiresIn</span> <span class="c">// ← se SOBREESCRIBE</span></pre>
<p>Lo que viaja en la respuesta es <code>expiresIn</code>: segundos desde epoch.</p>
<div class="note is-crit">
<p><strong>Pero hay algo más grave.</strong> <code>Login</code> llama a
<code>CreateToken</code> sin pasarle vencimiento (<code>pkg/auth/user.go:98</code>),
así que aplica el default: <strong>90 años</strong>.</p>
<p>En la práctica ese token nunca expira, y toda la lógica de caché y renovación
del cliente no se ejerce jamás. Es un arreglo nuestro, no de ustedes.</p>
</div>
</div>
</section>
<!-- 2 -->
<section class="q sev-ok">
<div class="q-num">02</div>
<div class="q-body">
<span class="chip chip-ok">Contrato confirmado</span>
<h2>Respuesta de <code>POST /oauth/token</code></h2>
<pre>{
"<span class="hl">token</span>": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"<span class="hl">expires_in</span>": 4626547200
}</pre>
<p class="cite">rest/controllers/api/auth_controller.go:42-45</p>
<ul>
<li>El campo es <code>token</code>, <strong>no</strong> <code>access_token</code>.</li>
<li><code>expires_in</code> está en el nivel raíz.</li>
<li>No hay <code>token_type</code>, ni <code>refresh_token</code>, ni envoltorio.</li>
</ul>
<p>Error de credenciales, siempre HTTP 401:</p>
<pre>{ "error": true, "message": "Invalid Credentials" }</pre>
</div>
</section>
<!-- 3 -->
<section class="q sev-warn">
<div class="q-num">03</div>
<div class="q-body">
<span class="chip chip-warn">Plano, con una excepción</span>
<h2>QR y VCF devuelven JSON sin envoltorio</h2>
<div class="scroll">
<table>
<thead>
<tr><th>Endpoint</th><th>Método</th><th>Respuesta</th></tr>
</thead>
<tbody>
<tr><td><code>/generate-qr-tmp</code></td><td>POST</td><td><code>{"url": "https://…"}</code></td></tr>
<tr><td><code>/generate-url-qr</code></td><td>POST</td><td><code>{"url": "https://…"}</code></td></tr>
<tr><td><code>/generate-vcf</code></td><td>POST</td><td><code>{"success": true, "url": "https://…"}</code></td></tr>
<tr><td><code>/generate-qr</code></td><td>POST</td><td><strong>No es JSON</strong> — imagen binaria <code>image/webp</code></td></tr>
</tbody>
</table>
</div>
<p class="cite">vcard_qr.go:133-135 · vcard_vcf.go:103-106 · vcard_qr.go:169-170</p>
<p>Ojo con el último: <code>generate-qr</code> responde bytes de imagen. Un
<code>json_decode</code> sobre eso falla siempre.</p>
<p>Errores: <code>{"error": "Datos inválidos"}</code> con 400, o
<code>{"error": "&lt;detalle&gt;"}</code> con 500.</p>
</div>
</section>
<!-- 4 -->
<section class="q sev-ok">
<div class="q-num">04</div>
<div class="q-body">
<span class="chip chip-ok">Sí, el envoltorio raro es real</span>
<h2>dLocal devuelve JSON codificado como string</h2>
<p>Nadie lo adivinó mal. La doble codificación existe:</p>
<pre><span class="c">// rest/controllers/api/dlocal_controller.go:42-45</span>
return c.Status(200).JSON(fiber.Map{
"message": "Plan creado exitosamente.",
"response": <span class="hl">string(response)</span>, <span class="c">// ← el JSON, como STRING</span>
})</pre>
<p>Lo que llega:</p>
<pre>{
"message": "Plan creado exitosamente.",
"response": "<span class="hl">{\"id\":\"PLAN-123\",\"status\":\"active\"}</span>"
}</pre>
<p>Hay que hacer <code>json_decode</code> <strong>dos veces</strong>: una del cuerpo y
otra del campo <code>response</code>. Aplica igual a los 7 endpoints dLocal
(líneas 44, 77, 110, 137, 162, 188, 221).</p>
<p>El texto de <code>message</code> cambia según el endpoint, así que no conviene
usarlo para decidir nada.</p>
</div>
</section>
<!-- 5 -->
<section class="q sev-warn">
<div class="q-num">05</div>
<div class="q-body">
<span class="chip chip-warn">Hay dos textos, no uno</span>
<h2>El reintento automático cubre solo la mitad de los casos</h2>
<pre><span class="c">// rest/middlewares/auth.go:271-286</span>
token := c.Cookies("Verify-Rest-Token")
if token == "" {
return c.Status(401).JSON(<span class="hl">"Token not found"</span>) <span class="c">// falta la cookie</span>
}
… ErrorHandler:
return ctx.Status(401).JSON(<span class="hl">"Invalid Attempt"</span>) <span class="c">// cookie inválida o vencida</span></pre>
<p>Los dos son <strong>JSON string, con comillas incluidas</strong> — el cuerpo
literal es <code>"Invalid Attempt"</code>, 16 bytes, no el texto pelado.</p>
<div class="note">
<p><strong>Recomendación:</strong> reintentar ante cualquier 401, sin mirar el
texto. Es más robusto y no depende de una redacción que puede cambiar.</p>
</div>
</div>
</section>
<!-- 6 -->
<section class="q sev-ok">
<div class="q-num">06</div>
<div class="q-body">
<span class="chip chip-ok">Una sola raíz</span>
<h2>No hay hosts separados por grupo</h2>
<p>Todo cuelga de <code>https://admin.u-site.app/api/v1/</code>. Lo correcto es
guardar <strong>la raíz</strong> y concatenar la ruta, en vez de guardar la URL del
token y derivar las demás con <code>str_replace</code>.</p>
<pre>POST /api/v1/oauth/token
POST /api/v1/generate-qr-tmp
POST /api/v1/generate-qr <span class="c">(imagen)</span>
POST /api/v1/generate-vcf
POST /api/v1/generate-url-qr
POST /api/v1/dlocal/subscription/crear-plan
GET /api/v1/dlocal/subscription/ver-plan/:planID
PATCH /api/v1/dlocal/subscription/actualizar-plan/:planID
GET /api/v1/dlocal/subscription/plan/all
PATCH /api/v1/dlocal/subscription/plan/:planId/subscription/:subscriptionId/deactivate
GET /api/v1/dlocal/subscription/:subscriptionId/execution/:invoiceId
POST /api/v1/dlocal/payment/crear-pago
POST /api/v1/rapyd/wallet/create</pre>
</div>
</section>
<!-- 7 -->
<section class="q sev-crit">
<div class="q-num">07</div>
<div class="q-body">
<span class="chip chip-crit">Crítico</span>
<h2>El header <code>Cookie</code> es obligatorio, y hoy está roto</h2>
<p>La API <strong>no acepta Bearer</strong>. Autentica exclusivamente por cookie:</p>
<pre><span class="c">// rest/middlewares/auth.go:272-281</span>
token := c.Cookies(<span class="hl">"Verify-Rest-Token"</span>)
if token == "" { return c.Status(401).JSON("Token not found") }
TokenLookup: <span class="hl">"cookie:Verify-Rest-Token"</span></pre>
<div class="note is-crit">
<p>Con el placeholder literal <code>...</code> sin completar, <strong>los 12
endpoints autenticados devuelven 401</strong>. Si algo funciona hoy, es porque el
cliente HTTP tiene cookie jar y reusa la cookie que <code>POST /oauth/token</code>
deja seteada en la respuesta (<code>config/token.go:40-47</code>).</p>
</div>
<ul>
<li>Mandar el token en el body o como <code>Authorization: Bearer</code>
<strong>no autentica nada</strong>.</li>
<li><code>session_id</code> <strong>no hace falta</strong>: el middleware no lo
mira. Se puede quitar.</li>
</ul>
<p>Lo correcto: tomar el <code>token</code> de la respuesta de <code>oauth/token</code>
y mandarlo como <code>Cookie: Verify-Rest-Token=&lt;token&gt;</code>, o dejar que el
cliente maneje cookies solo.</p>
</div>
</section>
<!-- 8 -->
<section class="q sev-ok">
<div class="q-num">08</div>
<div class="q-body">
<span class="chip chip-info">No aplica</span>
<h2>No hay control por IP en <code>/api/v1</code></h2>
<p>Solo se valida la cookie JWT. No hay allowlist; no hace falta registrar la IP
del servidor de producción.</p>
<p>Sí existe validación por IP, pero en <strong>otra</strong> API:
<code>/api/v2</code> usa <code>ApiKey</code> con IP obligatoria y scopes, y
<code>/api/v1/pagos-externos</code> usa <code>AuthServicioPago</code> — ambas
excluidas de <code>AuthApi</code> (<code>auth.go:266-268</code>).</p>
<div class="note">
<p>Si prefieren un esquema con IP fija y token que no viva en una cookie,
<strong><code>/api/v2</code> es el camino</strong>, y conviene migrar ahí en vez de
arreglar el actual.</p>
</div>
</div>
</section>
<!-- 9 -->
<section class="q sev-warn">
<div class="q-num">09</div>
<div class="q-body">
<span class="chip chip-warn">Fuera de este documento</span>
<h2>Credenciales</h2>
<p>No van acá. Son las de un usuario real de la tabla <code>users</code>
(<code>login.CheckLogin()</code>, <code>auth_controller.go:28</code>). Que se
entreguen por un canal seguro.</p>
<p>Dado que llevan 15 meses sin rotar, conviene <strong>crear un usuario dedicado a
la integración</strong> en vez de reusar el de una persona.</p>
</div>
</section>
<!-- 10 -->
<section class="q sev-ok">
<div class="q-num">10</div>
<div class="q-body">
<span class="chip chip-info">Existen, pero no para esto</span>
<h2>No hay callback saliente hacia ustedes</h2>
<p>Hay webhooks entrantes de pago ya montados
(<code>rest/routes/publicas.go:22-33</code>): <code>/webhooks/bold</code>,
<code>/webhooks/dlocal</code>, <code>/webhooks/paypal</code>, más Coolify y Telegram.</p>
<p>Pero sirven para que <strong>la pasarela nos avise a nosotros</strong>, no para
avisarle a un tercero. Un callback hacia su sistema es desarrollo nuevo.</p>
<p>Mientras tanto, la alternativa ya construida es
<code>/api/v1/pagos-externos</code>, pensada justo para apps de terceros:</p>
<pre>GET /api/v1/pagos-externos/pasarelas
POST /api/v1/pagos-externos/solicitar
GET /api/v1/pagos-externos/:referencia/estado</pre>
<div class="note">
<p>Usa token Bearer + IP (no cookie), así que evita todo el problema del punto 07.
<strong>Para una integración nueva, recomendamos esta</strong> y no los endpoints
dLocal directos.</p>
</div>
</div>
</section>
<div class="todo">
<h2>Pendientes de nuestro lado</h2>
<p>No son preguntas: son cosas a corregir en <code>admin.u-site.app</code>.</p>
<ol>
<li><strong>Tokens de 90 años</strong> (<code>config/token.go:26</code>). Un token
filtrado es acceso permanente. Ponerle un vencimiento razonable — y recién ahí la
caché y el reintento del cliente van a tener sentido.</li>
<li><strong>La cookie se emite con <code>Secure: false</code></strong>
(<code>config/token.go:44</code>), así que viaja también por HTTP plano.</li>
<li><strong>Autenticación por cookie en una API máquina-a-máquina</strong> es el
problema de fondo: obliga a manejar cookie jar y no permite allowlist por IP.
<code>/api/v2</code> ya lo resuelve bien.</li>
<li><strong>La doble codificación de dLocal</strong> no aporta nada; devolver el
JSON anidado directo sería un cambio compatible si se versiona.</li>
</ol>
</div>
</div>
-217
View File
@@ -1,217 +0,0 @@
# 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.
+8 -17
View File
@@ -1,6 +1,6 @@
module github.com/sujit-baniya/fiber-boilerplate module github.com/sujit-baniya/fiber-boilerplate
go 1.26 go 1.25.0
require ( require (
github.com/BurntSushi/toml v1.6.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect
@@ -25,11 +25,12 @@ require (
github.com/pyroscope-io/pyroscope v0.37.2 github.com/pyroscope-io/pyroscope v0.37.2
github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect
github.com/sujit-baniya/flash v0.1.9 github.com/sujit-baniya/flash v0.1.9
github.com/sujit-baniya/ip v0.0.10
github.com/tklauser/go-sysconf v0.3.14 // indirect github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/valyala/bytebufferpool v1.0.0 github.com/valyala/bytebufferpool v1.0.0
github.com/xhit/go-simple-mail/v2 v2.16.0 github.com/xhit/go-simple-mail/v2 v2.16.0
golang.org/x/crypto v0.51.0 golang.org/x/crypto v0.51.0
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.44.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
gorm.io/driver/mysql v1.5.7 gorm.io/driver/mysql v1.5.7
@@ -42,11 +43,7 @@ require (
require ( require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
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/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
@@ -56,16 +53,12 @@ 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
@@ -73,26 +66,19 @@ require (
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
github.com/casbin/govaluate v1.2.0 // indirect github.com/casbin/govaluate v1.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/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
github.com/glebarez/sqlite v1.11.0 // indirect github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/logr v1.4.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-test/deep v1.1.0 // indirect github.com/go-test/deep v1.1.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
@@ -102,6 +88,7 @@ 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
@@ -129,6 +116,7 @@ require (
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/pyroscope-io/dotnetdiag v1.2.1 // indirect github.com/pyroscope-io/dotnetdiag v1.2.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -138,6 +126,7 @@ 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
@@ -149,6 +138,8 @@ 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
+20 -28
View File
@@ -33,8 +33,6 @@ 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=
@@ -97,6 +95,8 @@ github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6Ct
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
@@ -137,12 +137,6 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko= github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe h1:PmhRwLZ8qLtldQCBiydwdPFJI8WVQ936ux1cpgHLRb8=
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -175,12 +169,6 @@ 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=
@@ -218,8 +206,6 @@ github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7b
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
@@ -250,14 +236,9 @@ github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncV
github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM= github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
github.com/gobuffalo/here v0.6.7 h1:hpfhh+kt2y9JLDfhYUxxCRxQol540jsVfKUZzjlbp8o= github.com/gobuffalo/here v0.6.7 h1:hpfhh+kt2y9JLDfhYUxxCRxQol540jsVfKUZzjlbp8o=
github.com/gobuffalo/here v0.6.7/go.mod h1:vuCfanjqckTuRlqAitJz6QC4ABNnS27wLb816UhsPcc= github.com/gobuffalo/here v0.6.7/go.mod h1:vuCfanjqckTuRlqAitJz6QC4ABNnS27wLb816UhsPcc=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.1.0/go.mod h1:aG+lMkwy3LyVit4CnmYUbUdgjpc3UYOltvlJZ78rgQ0= github.com/gofiber/fiber/v2 v2.1.0/go.mod h1:aG+lMkwy3LyVit4CnmYUbUdgjpc3UYOltvlJZ78rgQ0=
github.com/gofiber/fiber/v2 v2.9.0/go.mod h1:Ah3IJikrKNRepl/HuVawppS25X7FWohwfCSRn7kJG28=
github.com/gofiber/fiber/v2 v2.44.0/go.mod h1:VTMtb/au8g01iqvHyaCzftuM/xmZgKOZCtFzz6CdV9w= github.com/gofiber/fiber/v2 v2.44.0/go.mod h1:VTMtb/au8g01iqvHyaCzftuM/xmZgKOZCtFzz6CdV9w=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
@@ -458,6 +439,9 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.11.8/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
@@ -482,8 +466,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
@@ -574,8 +556,9 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk=
github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/oschwald/maxminddb-golang v1.8.0/go.mod h1:RXZtst0N6+FY/3qCNmZMBApR19cdQj43/NM9VkrNAis=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
@@ -686,6 +669,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/sujit-baniya/flash v0.1.9 h1:9+g9GrauXF8JH7ivoG4XBjl04r+vNkB9ZFN+5RKTugo= github.com/sujit-baniya/flash v0.1.9 h1:9+g9GrauXF8JH7ivoG4XBjl04r+vNkB9ZFN+5RKTugo=
github.com/sujit-baniya/flash v0.1.9/go.mod h1:nNyzxxxU11ctsPe99BhV39kP6/jcfoRDp4T7P5m4K4E= github.com/sujit-baniya/flash v0.1.9/go.mod h1:nNyzxxxU11ctsPe99BhV39kP6/jcfoRDp4T7P5m4K4E=
github.com/sujit-baniya/ip v0.0.10 h1:4FxdNgibzkDGS6B1+2//o4nStxmvD11i3NEgljh6SLk=
github.com/sujit-baniya/ip v0.0.10/go.mod h1:9wktAv0khHVF2t6lIWRbNV4ScqcHSi92pc5RfBb9YV8=
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw= github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
@@ -702,6 +687,8 @@ github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqri
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA= github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA=
github.com/valyala/fasthttp v1.23.0/go.mod h1:0mw2RjXGOzxf4NL2jni3gUQ7LfjjUSiG5sskOUUSEpU=
github.com/valyala/fasthttp v1.24.0/go.mod h1:0mw2RjXGOzxf4NL2jni3gUQ7LfjjUSiG5sskOUUSEpU=
github.com/valyala/fasthttp v1.45.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= github.com/valyala/fasthttp v1.45.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
github.com/valyala/fasthttp v1.56.0 h1:bEZdJev/6LCBlpdORfrLu/WOZXXxvrUQSiyniuaoW8U= github.com/valyala/fasthttp v1.56.0 h1:bEZdJev/6LCBlpdORfrLu/WOZXXxvrUQSiyniuaoW8U=
github.com/valyala/fasthttp v1.56.0/go.mod h1:sReBt3XZVnudxuLOx4J/fMrJVorWRiWY2koQKgABiVI= github.com/valyala/fasthttp v1.56.0/go.mod h1:sReBt3XZVnudxuLOx4J/fMrJVorWRiWY2koQKgABiVI=
@@ -766,6 +753,7 @@ golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
@@ -859,6 +847,7 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226101413-39120d07d75e/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
@@ -935,6 +924,7 @@ golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -962,6 +952,7 @@ golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -996,8 +987,9 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
+4 -127
View File
@@ -3,19 +3,14 @@ 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"
"github.com/sujit-baniya/fiber-boilerplate/utils"
) )
func main() { func main() {
@@ -37,13 +32,6 @@ 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: "*",
@@ -61,13 +49,8 @@ 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
// AutoMigrate(a, b, c...) se detiene en el primer modelo que falle: todo lo app.Http.Database.DB.AutoMigrate(
// 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{},
@@ -102,19 +85,12 @@ func main() {
&models.PortalPasswordResetToken{}, &models.PortalPasswordResetToken{},
// Integración VCard API Admin // Integración VCard API Admin
&models.VcardApiConfig{}, &models.VcardApiConfig{},
// Integración Coolify (multi-instancia) // Integración Coolify
&models.CoolifyConfig{}, &models.CoolifyConfig{},
// AI Config: campos del agente Telegram
&models.AiConfig{},
// Agente Telegram: historial y chats autorizados
&models.TelegramAgentHistory{},
&models.TelegramAgentAuth{},
// Servidores: nuevos campos de agente + tabla join de integraciones // Servidores: nuevos campos de agente + tabla join de integraciones
&models.Servidor{}, &models.Servidor{},
&models.ConxDb{}, &models.ConxDb{},
&models.ProvServidor{}, &models.ProvServidor{},
// SMTP
&models.SmtpConfig{},
// WebSMS (LabsMobile) // WebSMS (LabsMobile)
&models.WebSmsConfig{}, &models.WebSmsConfig{},
&models.WebSmsLog{}, &models.WebSmsLog{},
@@ -126,53 +102,7 @@ func main() {
&models.CuentaCobro{}, &models.CuentaCobro{},
&models.CuentaPagar{}, &models.CuentaPagar{},
&models.ConsolidadoMensual{}, &models.ConsolidadoMensual{},
// Historial de métricas del agente )
&models.ServidorMetricasHistory{},
// Monitor de disponibilidad de URLs
&models.UrlMonitor{},
&models.UrlMonitorLog{},
// Tablero de tareas
&models.Tarea{},
&models.TareaComentario{},
// Pagos externos: API de cobros para aplicaciones de terceros
&models.ServicioPagoExterno{},
&models.SolicitudPagoExterna{},
// Soporte: webhook de correo entrante
&models.SoporteWebhookConfig{},
// Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA
&models.PlantillaDocumento{},
&models.Tarifa{},
&models.DocumentoGenerado{},
&models.Arquitectura{},
// Vinculación de Telegram para staff interno
&models.TelegramStaffToken{},
// uMind: chat con IA embebible por tenant (widget web + RAG)
&models.UmindTenant{},
&models.UmindAgente{},
&models.UmindDocumento{},
&models.UmindChunk{},
&models.UmindMensaje{},
&models.UmindHerramienta{},
&models.UmindCanal{},
&models.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()
migrations.SeedIntegraciones() migrations.SeedIntegraciones()
@@ -187,67 +117,14 @@ func main() {
migrations.SeedShield() migrations.SeedShield()
migrations.SeedPartnerRecursos() migrations.SeedPartnerRecursos()
models.SeedContabilidad() models.SeedContabilidad()
// 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() models.SeedBalanceData()
}
migrations.SeedContabilidadMenu() migrations.SeedContabilidadMenu()
migrations.SeedWebSms() migrations.SeedWebSms()
migrations.SeedUrlMonitor()
migrations.SeedTareas()
migrations.SeedPagosExternos()
migrations.SeedAutomatizacionIA()
migrations.SeedUmind()
migrations.SeedDocumentacion()
// Va antes de MigrarUmindAgentes: libera las columnas huérfanas que
// dejó el refactor multi-agente. Sin esto no se puede insertar nada en
// uMind (ver el comentario de la función).
migrations.LiberarColumnasHuerfanasUmind()
migrations.IndicesUnicosMessageID()
migrations.MigrarUmindAgentes()
migrations.SeedApiKeys()
if n, err := models.RepararEstadosTareaInvalidos(); err != nil {
log.Printf("[FIX] Error reparando estados de tareas: %v", err)
} else if n > 0 {
log.Printf("[FIX] %d tarea(s) con estado inválido reparadas (ahora visibles en el Kanban)", n)
}
// Cargar config SMTP desde BD (sobreescribe valores del .env/config.yml si hay registro activo)
if smtpCfg, err := models.GetSmtpConfig(); err == nil {
app.Http.Mail.Host = smtpCfg.Host
app.Http.Mail.Port = smtpCfg.Port
app.Http.Mail.Username = smtpCfg.Username
app.Http.Mail.Password = utils.Decrypt(smtpCfg.Password, app.Http.Server.Key)
app.Http.Mail.Encryption = smtpCfg.Encryption
app.Http.Mail.FromAddress = smtpCfg.FromAddress
app.Http.Mail.FromName = smtpCfg.FromName
app.Http.Mail.SetupMailer()
log.Println("[SMTP] Configuración cargada desde base de datos")
} else {
log.Println("[SMTP] Sin config en BD, usando valores del entorno")
}
// Iniciar cron de vencimientos // Iniciar cron de vencimientos
services.IniciarCron() services.IniciarCron()
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())
} }
+102 -763
View File
File diff suppressed because it is too large Load Diff
-53
View File
@@ -1,53 +0,0 @@
package migrations
import (
"testing"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// El refactor multi-agente dejó columnas viejas con su NOT NULL original, y
// GORM no las toca. El síntoma en producción es:
//
// null value in column "site_key" violates not-null constraint (23502)
//
// Si un nombre de tabla acá no coincide con el TableName() real, la migración
// consulta information_schema, no encuentra nada y sigue de largo sin avisar:
// el bug quedaría igual y el arranque se vería sano. Por eso se comparan
// contra los modelos en vez de confiar en las cadenas escritas a mano.
func TestTablasHuerfanasCoincidenConLosModelos(t *testing.T) {
reales := map[string]bool{
models.UmindTenant{}.TableName(): true,
models.UmindDocumento{}.TableName(): true,
models.UmindChunk{}.TableName(): true,
models.UmindMensaje{}.TableName(): true,
models.UmindHerramienta{}.TableName(): true,
models.UmindCanal{}.TableName(): true,
models.UmindConexion{}.TableName(): true,
}
for _, h := range ColumnasHuerfanasUmind {
if !reales[h.Tabla] {
t.Errorf("la tabla %q no corresponde a ningún TableName() de uMind — la migración no encontraría la columna", h.Tabla)
}
}
// Las siete tablas afectadas tienen que estar cubiertas: si falta una,
// insertar en ella sigue fallando.
if len(ColumnasHuerfanasUmind) != len(reales) {
t.Errorf("hay %d entradas para %d tablas afectadas", len(ColumnasHuerfanasUmind), len(reales))
}
}
// Las columnas huérfanas ya no deben existir en los structs: si alguna volvió
// a mapearse, liberar su NOT NULL sería incorrecto.
func TestLasColumnasHuerfanasYaNoSeMapean(t *testing.T) {
// UmindTenant ya no debe tener SiteKey; vive en UmindAgente.
if _, tiene := any(models.UmindAgente{}).(interface{ TableName() string }); !tiene {
t.Skip("modelo inesperado")
}
a := models.UmindAgente{SiteKey: "umk_x"}
if a.SiteKey != "umk_x" {
t.Error("UmindAgente debería ser el dueño de SiteKey")
}
}
-1
View File
@@ -1 +0,0 @@
node_modules/
-16
View File
@@ -1,16 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
<title>uMind Studio</title>
</head>
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
que son los que cambian con el tema. Una utilidad acá le ganaba a la
capa base y dejaba el fondo claro también en modo oscuro. -->
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
-21
View File
@@ -1,21 +0,0 @@
{
"name": "umind-orchestrator",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.7"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-39
View File
@@ -1,39 +0,0 @@
<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>
@@ -1,117 +0,0 @@
<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>
@@ -1,51 +0,0 @@
<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>
-285
View File
@@ -1,285 +0,0 @@
<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>
@@ -1,16 +0,0 @@
<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>
@@ -1,30 +0,0 @@
<script setup>
import UiMascota from './UiMascota.vue'
// Una pantalla vacía es la primera que ve alguien que recién empieza, y la que
// más veces ve el que todavía no configuró algo. Decir "Sin canales" y nada
// más deja al usuario resolviendo solo qué significa eso y qué hacer.
//
// La mascota lleva el estado: dormida cuando no pasó nada todavía, buscando
// cuando falta cargar información, alerta cuando algo se rompió. Se lee antes
// que el texto.
defineProps({
// Estado de Umi. Sin él, cae en 'durmiendo', que es el caso más común.
estado: { type: String, default: 'durmiendo' },
titulo: String,
detalle: String,
// Escape para los pocos casos donde un emoji dice más que la mascota.
icono: { type: String, default: '' },
})
</script>
<template>
<div class="flex flex-col items-center justify-center text-center py-12 px-6">
<div v-if="icono" class="text-3xl mb-3 opacity-70">{{ icono }}</div>
<UiMascota v-else :estado="estado" :tam="64" class="mb-3 text-brand" />
<p class="text-sm font-medium text-texto">{{ titulo }}</p>
<p v-if="detalle" class="text-xs text-tenue mt-1.5 max-w-sm leading-relaxed">{{ detalle }}</p>
<div v-if="$slots.default" class="mt-4"><slot /></div>
</div>
</template>
-111
View File
@@ -1,111 +0,0 @@
<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>
@@ -1,118 +0,0 @@
<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>
@@ -1,21 +0,0 @@
<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>
-41
View File
@@ -1,41 +0,0 @@
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' }),
}
-21
View File
@@ -1,21 +0,0 @@
// El mismo build se sirve en dos lugares: /orchestrator (staff, sesión de
// panel) y /portal/studio (cliente, sesión de portal). Ambos exponen los
// mismos endpoints de uMind pero bajo distinto prefijo y con distinto
// alcance, así que la app deduce dónde está parada mirando la URL.
const enPortal = window.location.pathname.startsWith('/portal/')
export const contexto = {
esPortal: enPortal,
// Base del router (vue-router en modo history).
baseRuta: enPortal ? '/portal/studio/' : '/studio/',
// Prefijo de la API de uMind.
apiBase: enPortal ? '/portal' : '/app',
// A dónde mandar al usuario cuando se le venció la sesión.
urlLogin: enPortal ? '/portal/login' : '/login',
}
// apiUmind arma la ruta de un endpoint de uMind para el contexto actual:
// apiUmind('/umind/agentes') → '/app/umind/agentes' o '/portal/umind/agentes'
export function apiUmind(path) {
return contexto.apiBase + path
}
-23
View File
@@ -1,23 +0,0 @@
import { ref } from 'vue'
// El tema se aplica antes de montar la app para que no haya un parpadeo
// claro en quien tiene el oscuro elegido.
const GUARDADO = 'umind-tema'
function preferidoDelSistema() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'oscuro' : 'claro'
}
export const tema = ref(localStorage.getItem(GUARDADO) || preferidoDelSistema())
export function aplicarTema() {
document.documentElement.classList.toggle('dark', tema.value === 'oscuro')
}
export function alternarTema() {
tema.value = tema.value === 'oscuro' ? 'claro' : 'oscuro'
localStorage.setItem(GUARDADO, tema.value)
aplicarTema()
}
aplicarTema()
-6
View File
@@ -1,6 +0,0 @@
import { ref } from 'vue'
// Estado del menú lateral en móvil. Vive acá y no en App.vue porque lo tocan
// dos componentes que no son padre/hijo: el botón del header y el propio
// sidebar, que se cierra solo al navegar.
export const menuAbierto = ref(false)
-7
View File
@@ -1,7 +0,0 @@
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')
-31
View File
@@ -1,31 +0,0 @@
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
-145
View File
@@ -1,145 +0,0 @@
@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; }
}
}
-672
View File
@@ -1,672 +0,0 @@
<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">&larr; Agentes</router-link>
<div v-if="agente" class="mb-4 mt-1 flex items-start justify-between gap-4">
<h1 class="text-xl font-semibold text-texto min-w-0 truncate">{{ agente.nombre }}</h1>
<!-- Un archivo que se reenvía sirve para justificar el gasto puertas
adentro; un panel al que hay que entrar, no. -->
<a :href="urlReporte" class="btn-ghost shrink-0" title="Conversaciones y consumo del mes en Excel">
<UiIcono nombre="descargar" :tam="15" /> Reporte del mes
</a>
</div>
<!-- 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>
-259
View File
@@ -1,259 +0,0 @@
<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>
-170
View File
@@ -1,170 +0,0 @@
<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>
-81
View File
@@ -1,81 +0,0 @@
<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>
-136
View File
@@ -1,136 +0,0 @@
<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>
-174
View File
@@ -1,174 +0,0 @@
<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>
-340
View File
@@ -1,340 +0,0 @@
<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>
-210
View File
@@ -1,210 +0,0 @@
<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>
@@ -1,110 +0,0 @@
<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>
@@ -1,107 +0,0 @@
<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>
@@ -1,125 +0,0 @@
<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>
@@ -1,49 +0,0 @@
<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>
@@ -1,338 +0,0 @@
<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">&lt;/body&gt;</code> en las páginas de tu sitio.
</p>
<pre class="bg-elevado border border-borde rounded-lg p-2.5 text-xs text-texto overflow-x-auto"><code>{{ widgetSnippet }}</code></pre>
</div>
<div class="flex justify-end mb-4">
<button class="btn-primary" @click="nuevoCanal">
+ Nuevo canal
</button>
</div>
<div class="card divide-y divide-borde">
<UiEmptyState
v-if="canales.length === 0"
titulo="No está atendiendo en ningún lado"
detalle="Conectá WhatsApp o Telegram para que empiece a responderle a tus clientes. El widget de tu web funciona aparte, con la clave de sitio de arriba."
/>
<div v-for="c in canales" :key="c.ID" class="p-4">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-texto capitalize">{{ c.tipo }}</span>
<span 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&#10;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>
-65
View File
@@ -1,65 +0,0 @@
<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>
@@ -1,175 +0,0 @@
<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>
@@ -1,129 +0,0 @@
<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>
@@ -1,188 +0,0 @@
<script setup>
import { ref } from 'vue'
import { api } from '../../lib/api.js'
import { apiUmind } from '../../lib/contexto.js'
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
import UiIcono from '../../components/ui/UiIcono.vue'
const props = defineProps({
agenteId: { type: Number, required: true },
tools: { type: Array, default: () => [] },
})
// El padre es dueño de la lista: acá se avisa que cambió y él la recarga.
const emit = defineEmits(['recargar', 'error'])
const showToolForm = ref(false)
const editingTool = ref(null)
const toolForm = ref(toolVacio())
function toolVacio() {
return {
nombre: '', descripcion: '', url: '', auth_header_nombre: '', auth_header_valor: '',
tocarAuth: false, parametros: [], activa: true,
}
}
function nuevaTool() {
editingTool.value = null
toolForm.value = toolVacio()
showToolForm.value = true
}
function editarTool(t) {
editingTool.value = t
let parametros = []
try {
parametros = JSON.parse(t.parametros_json || '[]') || []
} catch {
parametros = []
}
toolForm.value = {
nombre: t.nombre, descripcion: t.descripcion, url: t.url,
auth_header_nombre: t.auth_header_nombre, auth_header_valor: '', tocarAuth: false,
parametros, activa: t.activa,
}
showToolForm.value = true
}
function agregarParametro() {
toolForm.value.parametros.push({ nombre: '', tipo: 'string', descripcion: '', requerido: false })
}
function quitarParametro(i) {
toolForm.value.parametros.splice(i, 1)
}
async function guardarTool() {
const payload = {
agente_id: props.agenteId,
nombre: toolForm.value.nombre.trim(),
descripcion: toolForm.value.descripcion,
url: toolForm.value.url.trim(),
auth_header_nombre: toolForm.value.auth_header_nombre,
parametros: toolForm.value.parametros,
activa: toolForm.value.activa,
}
if (toolForm.value.tocarAuth) {
payload.auth_header_valor = toolForm.value.auth_header_valor
}
try {
if (editingTool.value) {
await api.put(apiUmind(`/umind/tools/${editingTool.value.ID}`), payload)
} else {
await api.post(apiUmind('/umind/tools'), payload)
}
showToolForm.value = false
emit('recargar')
} catch (e) {
emit('error', e.message)
}
}
async function eliminarTool(t) {
if (!confirm(`¿Eliminar la herramienta "${t.nombre}"?`)) return
await api.del(apiUmind(`/umind/tools/${t.ID}`))
emit('recargar')
}
</script>
<template>
<div>
<div class="flex justify-between items-center mb-4">
<p class="label">Máximo 10 herramientas activas por agente.</p>
<button class="btn-primary" @click="nuevaTool">
+ Nueva herramienta
</button>
</div>
<div class="card divide-y divide-borde">
<UiEmptyState
v-if="tools.length === 0"
titulo="Sin herramientas conectadas"
detalle="Las herramientas le dejan consultar tus sistemas mientras conversa: stock, estado de un pedido, disponibilidad de turnos. Sin ninguna, responde solo con lo que tiene cargado."
/>
<div v-for="t in tools" :key="t.ID" class="p-4 flex items-center justify-between">
<div>
<div class="text-sm text-texto font-mono">{{ t.nombre }}</div>
<div class="label mt-0.5">{{ t.descripcion }}</div>
<div class="text-xs text-tenue mt-0.5">
{{ t.url }}
<span v-if="t.auth_configurado" class="ml-1 text-green-600 dark:text-green-400">· auth configurada</span>
<span v-if="!t.activa" class="ml-1 text-gray-400">· inactiva</span>
</div>
</div>
<div class="flex gap-3 text-sm shrink-0">
<button class="text-tenue hover:text-gray-800 dark:hover:text-gray-100" @click="editarTool(t)">Editar</button>
<button class="text-red-500 hover:text-red-700" @click="eliminarTool(t)">Eliminar</button>
</div>
</div>
</div>
<div v-if="showToolForm" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" @click.self="showToolForm = false">
<div class="card p-6 w-full max-w-xl max-h-[85vh] overflow-y-auto">
<h2 class="font-semibold text-texto mb-4">{{ editingTool ? 'Editar herramienta' : 'Nueva herramienta' }}</h2>
<form class="space-y-3" @submit.prevent="guardarTool">
<div>
<label class="label">Nombre (identificador, ej: consultar_stock)</label>
<input v-model="toolForm.nombre" required pattern="[a-z][a-z0-9_]{2,63}" class="input font-mono" />
</div>
<div>
<label class="label">Descripción (esto lo lee el modelo para decidir cuándo usarla)</label>
<textarea v-model="toolForm.descripcion" rows="2" required class="input"></textarea>
</div>
<div>
<label class="label">URL del webhook (https)</label>
<input v-model="toolForm.url" type="url" required placeholder="https://..." class="input" />
</div>
<div class="border border-borde rounded-lg p-3 space-y-2">
<div class="flex items-center justify-between">
<label class="label">Parámetros que completa el modelo</label>
<button type="button" class="text-xs text-brand" @click="agregarParametro">+ agregar</button>
</div>
<div v-for="(p, i) in toolForm.parametros" :key="i" class="flex gap-2 items-center">
<input v-model="p.nombre" placeholder="nombre" class="flex-1 border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs font-mono" />
<select v-model="p.tipo" class="border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs">
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
<input v-model="p.descripcion" placeholder="descripción" class="flex-1 border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs" />
<label class="label flex items-center gap-1">
<input v-model="p.requerido" type="checkbox" /> req.
</label>
<button type="button" class="text-red-400 text-xs" @click="quitarParametro(i)"><UiIcono nombre="cerrar" :tam="13" /></button>
</div>
<p v-if="toolForm.parametros.length === 0" class="text-xs text-gray-400">Sin parámetros.</p>
</div>
<div class="border border-borde rounded-lg p-3 space-y-2">
<label class="label">Autenticación saliente (opcional)</label>
<input v-model="toolForm.auth_header_nombre" placeholder="Nombre del header, ej: Authorization" class="input" />
<label class="flex items-center gap-2 label">
<input v-model="toolForm.tocarAuth" type="checkbox" />
{{ editingTool ? 'Cambiar el valor del secreto' : 'Configurar valor' }}
</label>
<input
v-if="toolForm.tocarAuth"
v-model="toolForm.auth_header_valor"
type="password"
placeholder="Valor del header (ej: Bearer xxxx)"
class="input"
/>
</div>
<label class="flex items-center gap-2 text-sm text-texto">
<input v-model="toolForm.activa" type="checkbox" /> Activa
</label>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="btn-ghost" @click="showToolForm = false">Cancelar</button>
<button type="submit" class="btn-primary">Guardar</button>
</div>
</form>
</div>
</div>
</div>
<!-- Canales -->
</template>
-43
View File
@@ -1,43 +0,0 @@
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
// 'class' (y no 'media') para que el toggle del header pueda ganarle al
// sistema operativo. La clase la pone lib/tema.js antes de montar la app.
darkMode: 'class',
theme: {
extend: {
colors: {
// Mismo verde de marca que ya usa el widget embebible de uMind.
brand: {
DEFAULT: '#8eb02f',
dark: '#719026',
light: '#a8c94f',
},
// Superficies y texto salen de variables CSS (ver style.css) para no
// repetir el par claro/oscuro en cada elemento.
superficie: 'rgb(var(--superficie) / <alpha-value>)',
elevado: 'rgb(var(--elevado) / <alpha-value>)',
borde: 'rgb(var(--borde) / <alpha-value>)',
texto: 'rgb(var(--texto) / <alpha-value>)',
tenue: 'rgb(var(--tenue) / <alpha-value>)',
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'Segoe UI', 'sans-serif'],
},
keyframes: {
aparecer: {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
escalar: {
'0%': { opacity: '0', transform: 'scale(.97)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
},
animation: {
aparecer: 'aparecer .2s ease-out',
escalar: 'escalar .15s ease-out',
},
},
},
plugins: [],
}
-22
View File
@@ -1,22 +0,0 @@
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',
},
},
})
+2 -1
View File
@@ -10,7 +10,8 @@
"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": "",
+20 -221
View File
@@ -6,7 +6,6 @@ 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"
) )
@@ -19,63 +18,17 @@ 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"` // varias pueden estar activas: una por módulo IsActive bool `gorm:"default:true" json:"is_active"` // Solo uno activo a la vez
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)
// "landing" = exclusivo para Landing Generator // "landing" = exclusivo para Landing Generator
// "query_runner" = exclusivo para Query Runner SQL // "query_runner" = exclusivo para Query Runner SQL
Modulo string `gorm:"size:50;default:''" json:"modulo"` Modulo string `gorm:"size:50;default:''" json:"modulo"`
// Agente Telegram: si EsAgenteBot=true, esta config es el cerebro del bot administrador.
// Solo debe haber una config activa como agente a la vez.
EsAgenteBot bool `gorm:"default:false" json:"es_agente_bot"`
TelegramConfigID *uint `gorm:"index" json:"telegram_config_id"`
// TenantID acota la config a un tenant de uMind: null = config global del
// staff (el comportamiento histórico). Sin esto, el selector de IA le
// mostraría a cada cliente las claves de todos los demás.
TenantID *uint `gorm:"index" json:"tenant_id"`
} }
func (AiConfig) TableName() string { return "ai_configs" } 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
@@ -108,6 +61,21 @@ 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 {
@@ -139,80 +107,6 @@ 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.
func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
var ai AiConfig
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ? AND tenant_id IS NULL", true, true).First(&ai).Error; err != nil {
return nil, nil, fmt.Errorf("no hay agente bot configurado: %w", err)
}
if ai.TelegramConfigID == nil {
return &ai, nil, fmt.Errorf("el agente no tiene bot de Telegram asignado")
}
tg, err := GetTelegramConfigByID(*ai.TelegramConfigID)
if err != nil {
return &ai, nil, fmt.Errorf("bot de Telegram no encontrado: %w", err)
}
return &ai, tg, nil
}
// GetAgenteBotAiConfig retorna solo la config de IA marcada como agente (el mismo
// "cerebro" que usa el bot de Telegram), sin exigir que tenga un bot de Telegram
// asignado. La usa el chat propio del dashboard para compartir el mismo motor.
func GetAgenteBotAiConfig() (*AiConfig, error) {
var ai AiConfig
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ? AND tenant_id IS NULL", true, true).First(&ai).Error; err != nil {
return nil, fmt.Errorf("no hay agente configurado: %w", err)
}
return &ai, nil
}
// GetAiConfigForService retorna la config activa asignada al módulo indicado. // GetAiConfigForService retorna la config activa asignada al módulo indicado.
// Lógica de prioridad: // Lógica de prioridad:
// 1. Config activa con modulo conteniendo service (puede ser comma-separated) // 1. Config activa con modulo conteniendo service (puede ser comma-separated)
@@ -220,15 +114,7 @@ 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
// tenant_id IS NULL: las configs de un cliente son SUYAS y solo las usa su if err := app.Http.Database.DB.Where("is_active = ?", true).Order("id ASC").Find(&items).Error; err != nil {
// 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 {
@@ -253,94 +139,7 @@ func GetAiConfigForService(service string) (*AiConfig, error) {
} }
} }
// 3. Cualquier config activa como último recurso, pero nunca una que esté // 3. Cualquier config activa como último recurso
// dedicada a un servicio que no sabe conversar: la de embeddings devuelve log.Printf("[AI_CONFIG] No se encontró config para servicio '%s', usando cualquier activa", service)
// vectores y la de Whisper transcribe audio. Caer ahí daba errores del return &items[0], nil
// proveedor imposibles de relacionar con esta elección.
for i := range items {
if esConfigDeUsoEspecial(items[i].Modulo) {
continue
}
log.Printf("[AI_CONFIG] Sin config para %q, se usa %q (que no la declara)", service, items[i].Nombre)
return &items[i], nil
}
return nil, fmt.Errorf("no hay ninguna configuración de IA para %q: asignale ese módulo a una config en /app/ai-config", service)
}
// esConfigDeUsoEspecial marca los módulos cuyo endpoint no es de chat, así que
// no sirven como comodín para otra cosa.
func esConfigDeUsoEspecial(modulo string) bool {
for _, m := range SplitModulos(modulo) {
if m == "whisper" || m == "umind_embeddings" {
return true
}
}
return false
}
// HayAiConfigParaModulo dice si alguna config activa declara ese módulo.
// Sirve para elegir un módulo propio solo cuando el admin lo configuró, y si no
// caer al que se venía usando — GetAiConfigForService no lo distingue porque
// tiene fallback a la global y a cualquier activa.
func HayAiConfigParaModulo(modulo string) bool {
var items []AiConfig
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
return false
}
for i := range items {
for _, m := range SplitModulos(items[i].Modulo) {
if m == modulo {
return true
}
}
}
return false
}
// GetWhisperConfig retorna la config de IA activa etiquetada específicamente con
// el módulo "whisper" (transcripción de audio). A diferencia de
// GetAiConfigForService, NO cae a una config global: si el admin no configuró
// una explícitamente para whisper, es mejor avisar con claridad que intentar
// transcribir contra un proveedor que no soporta ese endpoint (ej. Anthropic).
func GetWhisperConfig() (*AiConfig, error) {
var items []AiConfig
// Solo configs del staff, por lo mismo que GetAiConfigForService.
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
}
for i := range items {
for _, m := range SplitModulos(items[i].Modulo) {
if m == "whisper" {
return &items[i], nil
}
}
}
return nil, fmt.Errorf("no hay ninguna configuración activa con el módulo 'whisper' en /app/ai-config")
}
// GetUmindEmbeddingsConfig retorna la config activa etiquetada con el módulo
// "umind_embeddings" (usada para generar los vectores de la base de
// conocimiento de todos los tenants de uMind). Es global, no por tenant: los
// embeddings de un tenant solo son comparables entre sí si se generaron con
// el mismo modelo, así que cambiar de config invalida los chunks existentes
// (habría que reingestar). Sin fallback, igual que GetWhisperConfig — Claude
// no ofrece embeddings, así que aquí sí importa exigir una config explícita
// en vez de caer a cualquier config activa.
func GetUmindEmbeddingsConfig() (*AiConfig, error) {
var items []AiConfig
// Global obligatoriamente: los vectores de todos los agentes tienen que
// salir del mismo modelo o la similitud coseno entre ellos no significa
// nada. Un cliente con su propio modelo de embeddings rompería su propia
// búsqueda sin ningún error visible.
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
}
for i := range items {
for _, m := range SplitModulos(items[i].Modulo) {
if m == "umind_embeddings" {
return &items[i], nil
}
}
}
return nil, fmt.Errorf("no hay ninguna configuración activa con el módulo 'umind_embeddings' en /app/ai-config (necesaria para generar embeddings, ej. un proveedor OpenAI)")
} }
-62
View File
@@ -1,62 +0,0 @@
package models
import (
"testing"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/config"
"github.com/sujit-baniya/fiber-boilerplate/utils"
)
// El resto del proyecto inicializa app.Http en el arranque; en tests hay que
// hacerlo a mano antes de tocar cualquier cosa que lea la config.
func conAppKey(t *testing.T, key string) {
t.Helper()
anterior := app.Http
app.Http = &config.AppConfig{}
app.Http.Server.Key = key
t.Cleanup(func() { app.Http = anterior })
}
// La migración progresiva de AiConfig.ApiKey depende de que ClaveEnClaro
// distinga una clave cifrada de una que todavía está en texto plano. Si esto
// se rompe, el sistema empieza a mandar ciphertext como API key a OpenAI y
// todos los agentes dejan de responder.
func TestClaveEnClaroMigracionProgresiva(t *testing.T) {
conAppKey(t, "6368616e676520746869732070617373776f726420746f206120736563726574") // 32 bytes en hex
casos := []struct {
nombre string
guardado string
esperado string
descripci string
}{
{"vacío", "", "", "sin clave no hay nada que descifrar"},
{"texto plano", "sk-proj-abc123", "sk-proj-abc123", "fila vieja sin cifrar, se devuelve tal cual"},
{"cifrada", utils.Encrypt("sk-proj-abc123", app.Http.Server.Key), "sk-proj-abc123", "fila nueva, se descifra"},
{"hex que no es ciphertext", "deadbeef", "deadbeef", "hex válido pero no descifrable: no debe romper"},
}
for _, cas := range casos {
t.Run(cas.nombre, func(t *testing.T) {
cfg := &AiConfig{ApiKey: cas.guardado}
if got := cfg.ClaveEnClaro(); got != cas.esperado {
t.Errorf("%s: ClaveEnClaro() = %q, esperaba %q", cas.descripci, got, cas.esperado)
}
})
}
}
// Sin APP_KEY el guardado no debe romperse: se guarda en claro y se lee en
// claro, que es exactamente el comportamiento previo a esta migración.
func TestCifrarClaveAiSinAppKey(t *testing.T) {
conAppKey(t, "")
if got := CifrarClaveAi("sk-test"); got != "sk-test" {
t.Errorf("CifrarClaveAi sin APP_KEY = %q, esperaba pasarla tal cual", got)
}
cfg := &AiConfig{ApiKey: "sk-test"}
if got := cfg.ClaveEnClaro(); got != "sk-test" {
t.Errorf("ClaveEnClaro sin APP_KEY = %q, esperaba pasarla tal cual", got)
}
}
-68
View File
@@ -1,68 +0,0 @@
package models
import (
"os"
"strings"
"testing"
)
// El fallback de GetAiConfigForService puede terminar usando una config que no
// declara el servicio pedido. Lo que no puede es agarrar una dedicada a
// embeddings o a Whisper: esos endpoints no conversan, y el error del proveedor
// no se parece en nada a la causa real.
func TestConfigsDeUsoEspecialNoSirvenDeComodin(t *testing.T) {
casos := map[string]bool{
"whisper": true,
"umind_embeddings": true,
"landing,umind_embeddings": true,
"": false,
"ia": false,
"landing,query_runner": false,
}
for modulo, want := range casos {
if got := esConfigDeUsoEspecial(modulo); got != want {
t.Errorf("esConfigDeUsoEspecial(%q) = %v, want %v", modulo, got, want)
}
}
}
// La consulta de todo resolvedor global tiene que excluir las configs de
// cliente. Sin ese filtro, la config que carga un cliente —que no lleva módulo,
// ninguna lo lleva— cae en el fallback y pasa a atender tareas nuestras:
// clasificar correos de soporte, importar plantillas, la vCard. Su cuenta
// pagando nuestro trabajo, y sin que se entere ninguno de los dos.
//
// Se verifica sobre el código porque son consultas a base: acá no hay una.
func TestLosResolvedoresGlobalesExcluyenConfigsDeCliente(t *testing.T) {
fuente, err := os.ReadFile("ai_config.go")
if err != nil {
t.Fatal(err)
}
texto := string(fuente)
// Toda función que resuelve una config para uso del sistema.
resolvedores := []string{
"func GetAiConfigForService(",
"func GetWhisperConfig(",
"func GetUmindEmbeddingsConfig(",
"func GetAgenteBotAiConfig(",
"func GetAgenteBotConfig(",
"func HayAiConfigParaModulo(",
}
for _, firma := range resolvedores {
i := strings.Index(texto, firma)
if i < 0 {
t.Errorf("no encontré %s — ¿se renombró?", firma)
continue
}
// El cuerpo hasta la próxima función de nivel superior.
resto := texto[i+len(firma):]
if j := strings.Index(resto, "\nfunc "); j > 0 {
resto = resto[:j]
}
if !strings.Contains(resto, "tenant_id IS NULL") {
t.Errorf("%s no filtra tenant_id IS NULL: puede devolver la config de un cliente para una tarea del sistema", firma)
}
}
}
-172
View File
@@ -1,172 +0,0 @@
package models
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"net"
"strings"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// ApiKey es una credencial para /api/v2, alternativa al ADMIN_API_KEY único de
// entorno (que sigue funcionando como llave maestra para no romper lo que ya
// depende de él). A diferencia de esa llave maestra, cada ApiKey:
// - Solo funciona desde la IP/CIDR que se le asignó (obligatoria, no opcional
// — a diferencia del patrón de Pagos Externos, aquí se decidió exigirla
// siempre porque esta llave puede dar acceso a datos internos, no solo a
// pedir un cobro).
// - Solo puede usar los scopes (grupos de endpoints) que se le habilitaron.
// - Se puede revocar individualmente sin afectar a otras integraciones.
type ApiKey struct {
gorm.Model
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
TokenHash string `json:"-" gorm:"column:token_hash;uniqueIndex;size:64"`
TokenPreview string `json:"token_preview" gorm:"column:token_preview;size:12"`
IPPermitida string `json:"ip_permitida" gorm:"column:ip_permitida;size:60;not null"` // IP exacta o CIDR, obligatoria
Scopes string `json:"scopes" gorm:"column:scopes;size:300"` // comma-separated, ej: "oss,query_runner"
Activa bool `json:"activa" gorm:"column:activa;default:true"`
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
UltimoUsoAt *time.Time `json:"ultimo_uso_at" gorm:"column:ultimo_uso_at"`
UltimoUsoIP string `json:"ultimo_uso_ip" gorm:"column:ultimo_uso_ip;size:60"`
}
func (ApiKey) TableName() string { return "api_keys" }
// GenerarApiKeyToken crea un token aleatorio de 32 bytes y su hash SHA-256,
// mismo esquema que ServicioPagoExterno: el token crudo se devuelve una sola
// vez, en la base solo queda el hash.
func GenerarApiKeyToken() (raw string, hash string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", fmt.Errorf("no se pudo generar el token: %w", err)
}
raw = "sak_" + hex.EncodeToString(b)
sum := sha256.Sum256([]byte(raw))
hash = hex.EncodeToString(sum[:])
return raw, hash, nil
}
func apiKeyTokenPreview(raw string) string {
if len(raw) <= 8 {
return raw
}
return "..." + raw[len(raw)-6:]
}
func CreateApiKey(k *ApiKey) (tokenPlano string, err error) {
raw, hash, err := GenerarApiKeyToken()
if err != nil {
return "", err
}
k.TokenHash = hash
k.TokenPreview = apiKeyTokenPreview(raw)
if err := app.Http.Database.DB.Create(k).Error; err != nil {
return "", err
}
return raw, nil
}
func RegenerarApiKeyToken(id uint) (tokenPlano string, err error) {
raw, hash, err := GenerarApiKeyToken()
if err != nil {
return "", err
}
result := app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id).
Updates(map[string]interface{}{"token_hash": hash, "token_preview": apiKeyTokenPreview(raw)})
if result.Error != nil {
return "", result.Error
}
if result.RowsAffected == 0 {
return "", fmt.Errorf("api key no encontrada")
}
return raw, nil
}
func GetAllApiKeys(limit, offset int) ([]ApiKey, int64, error) {
var items []ApiKey
var total int64
db := app.Http.Database.DB.Model(&ApiKey{})
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func GetApiKeyByID(id uint) (*ApiKey, error) {
var k ApiKey
if err := app.Http.Database.DB.First(&k, id).Error; err != nil {
return nil, err
}
return &k, nil
}
// FindApiKeyActivaByToken resuelve la llave a partir del token crudo recibido
// en el header Authorization/X-API-Key. Solo hace match si está activa.
func FindApiKeyActivaByToken(rawToken string) (*ApiKey, error) {
sum := sha256.Sum256([]byte(rawToken))
hash := hex.EncodeToString(sum[:])
var k ApiKey
if err := app.Http.Database.DB.Where("token_hash = ? AND activa = ?", hash, true).First(&k).Error; err != nil {
return nil, err
}
return &k, nil
}
func UpdateApiKey(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteApiKey(id uint) error {
return app.Http.Database.DB.Delete(&ApiKey{}, id).Error
}
// RegistrarUsoApiKey deja constancia de la última vez (y desde qué IP) que se
// usó la llave, para poder detectar llaves zombis o uso desde un origen raro.
func RegistrarUsoApiKey(id uint, ip string) {
now := time.Now()
app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id).
Updates(map[string]interface{}{"ultimo_uso_at": now, "ultimo_uso_ip": ip})
}
// IPPermitida valida la IP del caller contra el CIDR/IP exacta configurada.
// A diferencia de ServicioPagoExterno, aquí es obligatoria: una ApiKey sin
// IP válida configurada nunca debe dar acceso (fail-closed).
func (k *ApiKey) IPValida(ip string) bool {
entrada := strings.TrimSpace(k.IPPermitida)
if entrada == "" {
return false
}
callerIP := net.ParseIP(ip)
if callerIP == nil {
return false
}
if strings.Contains(entrada, "/") {
_, red, err := net.ParseCIDR(entrada)
return err == nil && red.Contains(callerIP)
}
permitida := net.ParseIP(entrada)
return permitida != nil && permitida.Equal(callerIP)
}
// ScopesList devuelve los scopes habilitados para esta llave.
func (k *ApiKey) ScopesList() []string {
return SplitModulos(k.Scopes)
}
// TieneScope indica si la llave puede usar ese grupo de endpoints.
func (k *ApiKey) TieneScope(scope string) bool {
for _, s := range k.ScopesList() {
if s == scope {
return true
}
}
return false
}
-60
View File
@@ -1,60 +0,0 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// Arquitectura guarda patrones de arquitectura técnica ya resueltos (ej: Active
// Directory redundante en Azure con VPN Gateway) para que la IA los reutilice como
// referencia al generar una propuesta técnica nueva, en vez de improvisar cada vez.
type Arquitectura struct {
gorm.Model
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
ContenidoHTML string `json:"contenido_html" gorm:"column:contenido_html;type:text"` // diagrama/detalle en HTML, reutilizable como referencia
Tags string `json:"tags" gorm:"column:tags;size:255"` // comma-separated, ej: "azure,ad,vpn"
EsReferencia bool `json:"es_referencia" gorm:"column:es_referencia;default:true"` // true = patrón reutilizable, false = propuesta generada puntual
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
}
func (Arquitectura) TableName() string { return "arquitecturas" }
func GetAllArquitecturas(limit, offset int, search string, soloReferencias bool) ([]Arquitectura, int64, error) {
var items []Arquitectura
var total int64
db := app.Http.Database.DB.Model(&Arquitectura{})
if search != "" {
db = db.Where("nombre ILIKE ? OR tags ILIKE ?", "%"+search+"%", "%"+search+"%")
}
if soloReferencias {
db = db.Where("es_referencia = ?", true)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("nombre ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func GetArquitecturaByID(id uint) (*Arquitectura, error) {
var item Arquitectura
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
func CreateArquitectura(a *Arquitectura) error {
return app.Http.Database.DB.Create(a).Error
}
func UpdateArquitectura(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&Arquitectura{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteArquitectura(id uint) error {
return app.Http.Database.DB.Delete(&Arquitectura{}, id).Error
}
+52 -221
View File
@@ -3,7 +3,6 @@ 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"
@@ -62,10 +61,8 @@ func (Transaccion) TableName() string { return "contab_transacciones" }
type CuentaCobro struct { type CuentaCobro struct {
gorm.Model gorm.Model
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;index;not null"` EntidadID uint `json:"entidad_id" gorm:"column:entidad_id;index;not null"`
Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"` Entidad Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
EntidadID *uint `json:"entidad_id" gorm:"column:entidad_id;index"`
Entidad *Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"` Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"` Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Valor float64 `json:"valor" gorm:"column:valor;not null"` Valor float64 `json:"valor" gorm:"column:valor;not null"`
@@ -94,11 +91,6 @@ 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" }
@@ -122,9 +114,7 @@ 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) {
// Inicializado para que sin filas el JSON sea [] y no null: null obliga var items []Cuenta
// 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 != "" {
@@ -140,9 +130,7 @@ func GetAllCuentas(limit, offset int, search string) ([]Cuenta, int64, error) {
} }
func GetAllCuentasSelect() ([]Cuenta, error) { func GetAllCuentasSelect() ([]Cuenta, error) {
// Inicializado para que sin filas el JSON sea [] y no null: null obliga var items []Cuenta
// 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
} }
@@ -172,9 +160,7 @@ 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) {
// Inicializado para que sin filas el JSON sea [] y no null: null obliga var items []Entidad
// 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 != "" {
@@ -191,9 +177,7 @@ func GetAllEntidades(limit, offset int, search string) ([]Entidad, int64, error)
} }
func GetAllEntidadesSelect() ([]Entidad, error) { func GetAllEntidadesSelect() ([]Entidad, error) {
// Inicializado para que sin filas el JSON sea [] y no null: null obliga var items []Entidad
// 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
} }
@@ -204,26 +188,6 @@ 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,
@@ -246,9 +210,7 @@ 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) {
// Inicializado para que sin filas el JSON sea [] y no null: null obliga var items []Transaccion
// 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 != "" {
@@ -270,21 +232,11 @@ func GetAllTransacciones(limit, offset int, search string, filtroTipo string, me
} }
func CreateTransaccion(t *Transaccion) error { func CreateTransaccion(t *Transaccion) error {
if err := app.Http.Database.DB.Create(t).Error; err != nil { return app.Http.Database.DB.Create(t).Error
return err
}
recomputarConsolidadoDeFecha(t.Fecha)
return nil
} }
// UpdateTransaccion actualiza una transacción y recalcula el consolidado mensual
// del mes al que queda asociada. Si además cambió de mes, también recalcula el
// mes anterior, para que ninguno de los dos quede desincronizado.
func UpdateTransaccion(t *Transaccion) error { func UpdateTransaccion(t *Transaccion) error {
var anterior Transaccion return app.Http.Database.DB.Model(&Transaccion{}).Where("id = ?", t.ID).Updates(map[string]interface{}{
tieneAnterior := app.Http.Database.DB.First(&anterior, t.ID).Error == nil
if err := app.Http.Database.DB.Model(&Transaccion{}).Where("id = ?", t.ID).Updates(map[string]interface{}{
"fecha": t.Fecha, "fecha": t.Fecha,
"tipo": t.Tipo, "tipo": t.Tipo,
"descripcion": t.Descripcion, "descripcion": t.Descripcion,
@@ -294,41 +246,11 @@ func UpdateTransaccion(t *Transaccion) error {
"forma_pago": t.FormaPago, "forma_pago": t.FormaPago,
"estado": t.Estado, "estado": t.Estado,
"notas": t.Notas, "notas": t.Notas,
}).Error; err != nil { }).Error
return err
}
recomputarConsolidadoDeFecha(t.Fecha)
if tieneAnterior && !mismoMesYAnio(anterior.Fecha, t.Fecha) {
recomputarConsolidadoDeFecha(anterior.Fecha)
}
return nil
} }
// DeleteTransaccion elimina una transacción y recalcula el consolidado mensual
// del mes al que pertenecía, para que no quede con montos que ya no existen.
func DeleteTransaccion(id uint) error { func DeleteTransaccion(id uint) error {
var t Transaccion return app.Http.Database.DB.Delete(&Transaccion{}, id).Error
tieneFecha := app.Http.Database.DB.First(&t, id).Error == nil
if err := app.Http.Database.DB.Delete(&Transaccion{}, id).Error; err != nil {
return err
}
if tieneFecha {
recomputarConsolidadoDeFecha(t.Fecha)
}
return nil
}
func mismoMesYAnio(a, b time.Time) bool {
return a.Year() == b.Year() && a.Month() == b.Month()
}
// recomputarConsolidadoDeFecha recalcula (o crea) el ConsolidadoMensual del mes
// de la fecha dada. Se ignora el error: el consolidado es derivado y se puede
// recalcular de nuevo en cualquier momento desde /contabilidad/consolidado.
func recomputarConsolidadoDeFecha(fecha time.Time) {
_, _ = CalcularYGuardarConsolidado(int(fecha.Month()), fecha.Year())
} }
// ============================================================================= // =============================================================================
@@ -336,15 +258,13 @@ 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) {
// Inicializado para que sin filas el JSON sea [] y no null: null obliga var items []CuentaCobro
// 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("Entidad").Preload("Transaccion")
if search != "" { if search != "" {
db = db.Joins("JOIN clientes ON clientes.id = contab_cuentas_cobro.cliente_id"). db = db.Joins("JOIN contab_entidades ON contab_entidades.id = contab_cuentas_cobro.entidad_id").
Where("clientes.nombre ILIKE ? OR clientes.empresa ILIKE ? OR contab_cuentas_cobro.descripcion ILIKE ?", Where("contab_entidades.nombre ILIKE ? OR contab_cuentas_cobro.descripcion ILIKE ?",
"%"+search+"%", "%"+search+"%", "%"+search+"%") "%"+search+"%", "%"+search+"%")
} }
if estado != "" { if estado != "" {
db = db.Where("contab_cuentas_cobro.estado = ?", estado) db = db.Where("contab_cuentas_cobro.estado = ?", estado)
@@ -385,11 +305,7 @@ func SeedBalanceData() {
} }
// ─── FACTURAS → Transacciones (ingresos) ──────────────────────────────── // ─── FACTURAS → Transacciones (ingresos) ────────────────────────────────
facturas := []struct { facturas := []struct{ factura float64; valor float64; fecha string }{
factura float64
valor float64
fecha string
}{
{54, 450000, "2026-01-01 00:00:00"}, {54, 450000, "2026-01-01 00:00:00"},
{55, 9000000, "2026-01-01 00:00:00"}, {55, 9000000, "2026-01-01 00:00:00"},
{56, 119000, "2026-01-01 00:00:00"}, {56, 119000, "2026-01-01 00:00:00"},
@@ -451,11 +367,7 @@ func SeedBalanceData() {
} }
// ─── IVA → Transacciones (egresos) ────────────────────────────────────── // ─── IVA → Transacciones (egresos) ──────────────────────────────────────
ivas := []struct { ivas := []struct{ valor float64; entidad string; desc string }{
valor float64
entidad string
desc string
}{
{285000, "DOCUXER", "IVA Fact #63"}, {285000, "DOCUXER", "IVA Fact #63"},
{503500, "DOCUXER", "IVA Fact #70"}, {503500, "DOCUXER", "IVA Fact #70"},
{19000, "GIAF SAS", "IVA Fact #56"}, {19000, "GIAF SAS", "IVA Fact #56"},
@@ -477,11 +389,7 @@ func SeedBalanceData() {
} }
// ─── RETENCION → Transacciones (egresos) ──────────────────────────────── // ─── RETENCION → Transacciones (egresos) ────────────────────────────────
rets := []struct { rets := []struct{ valor float64; fecha string; estado string }{
valor float64
fecha string
estado string
}{
{195000, "2026-03-01 00:00:00", "pagado"}, {195000, "2026-03-01 00:00:00", "pagado"},
{257000, "2026-02-01 00:00:00", "pagado"}, {257000, "2026-02-01 00:00:00", "pagado"},
{431000, "2026-01-01 00:00:00", "pendiente"}, {431000, "2026-01-01 00:00:00", "pendiente"},
@@ -499,12 +407,8 @@ func SeedBalanceData() {
} }
} }
// ─── CUENTAS DE COBRO (las que te pasan a ti) → CuentasPagar ──────────── // ─── CUENTAS DE COBRO ───────────────────────────────────────────────────
cobros := []struct { cobros := []struct{ entidad string; valor float64; fecha string }{
entidad string
valor float64
fecha string
}{
{"NATALIA", 2000000, "2026-01-01 00:00:00"}, {"NATALIA", 2000000, "2026-01-01 00:00:00"},
{"FELIPE", 2000000, "2026-01-01 00:00:00"}, {"FELIPE", 2000000, "2026-01-01 00:00:00"},
{"CONTADORA", 1780000, "2026-01-01 00:00:00"}, {"CONTADORA", 1780000, "2026-01-01 00:00:00"},
@@ -515,28 +419,36 @@ func SeedBalanceData() {
{"FELIPE", 520000, "2026-04-01 00:00:00"}, {"FELIPE", 520000, "2026-04-01 00:00:00"},
{"ANDREMER", 80000, "2026-04-01 00:00:00"}, {"ANDREMER", 80000, "2026-04-01 00:00:00"},
{"NATALIA", 2000000, "2026-04-01 00:00:00"}, {"NATALIA", 2000000, "2026-04-01 00:00:00"},
// FAC POR PAGAR
{"TECZONE", 2687849, "2026-01-01 00:00:00"},
} }
for _, cb := range cobros { for _, cb := range cobros {
eid := getEntidad(cb.entidad) eid := getEntidad(cb.entidad)
desc := fmt.Sprintf("%s %s", cb.entidad, cb.fecha[:7]) desc := fmt.Sprintf("Cobro %s %s", cb.entidad, cb.fecha[:7])
var existing CuentaPagar var existing CuentaCobro
if db.Where("descripcion = ?", desc).First(&existing).Error != nil { if db.Where("descripcion = ?", desc).First(&existing).Error != nil {
df := parseDate(cb.fecha) df := parseDate(cb.fecha)
db.Create(&CuentaPagar{ db.Create(&CuentaCobro{
EntidadID: *eid, Fecha: df, EntidadID: *eid, Fecha: df,
Descripcion: desc, Valor: cb.valor, Descripcion: desc, Valor: cb.valor,
Estado: "pendiente", Estado: "pendiente",
FechaVencimiento: &df,
}) })
} }
} }
// ─── FAC POR PAGAR ──────────────────────────────────────────────────────
var existingCp CuentaPagar
if db.Where("descripcion = ?", "TECZONE ene 2026").First(&existingCp).Error != nil {
eid := getEntidad("TECZONE")
f := parseDate("2026-01-01 00:00:00")
db.Create(&CuentaPagar{
EntidadID: *eid, Fecha: f,
Descripcion: "TECZONE ene 2026",
Valor: 2687849, Estado: "pendiente",
})
}
// ─── CONSOLIDADO POR MES ─────────────────────────────────────────────── // ─── CONSOLIDADO POR MES ───────────────────────────────────────────────
consols := []struct { consols := []struct{ mes int; ing, egre, resul float64 }{
mes int
ing, egre, resul float64
}{
{1, 12669000, 8467849, 4201151}, {1, 12669000, 8467849, 4201151},
{2, 8470500, 4300000, 4170500}, {2, 8470500, 4300000, 4170500},
{3, 2109000, 4300000, -2191000}, {3, 2109000, 4300000, -2191000},
@@ -556,62 +468,24 @@ func SeedBalanceData() {
log.Println("[SEED] Balance data imported from BALANCE.numbers") log.Println("[SEED] Balance data imported from BALANCE.numbers")
} }
func GetCuentaCobroByID(id uint) (*CuentaCobro, error) {
var item CuentaCobro
if err := app.Http.Database.DB.Preload("Cliente").Preload("Entidad").First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
func CreateCuentaCobro(cc *CuentaCobro) error { func CreateCuentaCobro(cc *CuentaCobro) error {
return app.Http.Database.DB.Create(cc).Error return app.Http.Database.DB.Create(cc).Error
} }
// UpdateCuentaCobro actualiza los campos editables de una cuenta por cobrar.
// No toca transaccion_id a propósito: ese vínculo solo lo debe crear
// MarcarCuentaCobroPagada, para no perderlo en una edición cualquiera.
func UpdateCuentaCobro(cc *CuentaCobro) error { func UpdateCuentaCobro(cc *CuentaCobro) error {
return app.Http.Database.DB.Model(&CuentaCobro{}).Where("id = ?", cc.ID).Updates(map[string]interface{}{ return app.Http.Database.DB.Model(&CuentaCobro{}).Where("id = ?", cc.ID).Updates(map[string]interface{}{
"cliente_id": cc.ClienteID, "entidad_id": cc.EntidadID,
"fecha": cc.Fecha,
"descripcion": cc.Descripcion,
"valor": cc.Valor,
"estado": cc.Estado, "estado": cc.Estado,
"fecha_vencimiento": cc.FechaVencimiento, "fecha_vencimiento": cc.FechaVencimiento,
"fecha_pago": cc.FechaPago, "fecha_pago": cc.FechaPago,
"transaccion_id": cc.TransaccionID,
"notas": cc.Notas, "notas": cc.Notas,
}).Error }).Error
} }
// MarcarCuentaCobroPagada registra el pago de una cuenta por cobrar: crea la
// Transaccion de tipo "ingreso" correspondiente y la vincula. Es idempotente —
// si la cuenta ya estaba pagada, no crea una transacción duplicada.
func MarcarCuentaCobroPagada(id uint, fechaPago time.Time) error {
var cc CuentaCobro
if err := app.Http.Database.DB.Preload("Cliente").First(&cc, id).Error; err != nil {
return err
}
if cc.Estado == "pagado" {
return nil
}
desc := fmt.Sprintf("Pago cuenta por cobrar #%d: %s", cc.ID, cc.Descripcion)
if cc.Cliente.Nombre != "" {
desc = fmt.Sprintf("Pago cuenta por cobrar #%d (%s): %s", cc.ID, cc.Cliente.Nombre, cc.Descripcion)
}
t := &Transaccion{
Fecha: fechaPago,
Tipo: "ingreso",
Descripcion: desc,
Valor: cc.Valor,
}
if err := CreateTransaccion(t); err != nil {
return fmt.Errorf("no se pudo crear la transacción de pago: %w", err)
}
return app.Http.Database.DB.Model(&CuentaCobro{}).Where("id = ?", id).Updates(map[string]interface{}{
"estado": "pagado",
"fecha_pago": fechaPago,
"transaccion_id": t.ID,
}).Error
}
func DeleteCuentaCobro(id uint) error { func DeleteCuentaCobro(id uint) error {
return app.Http.Database.DB.Delete(&CuentaCobro{}, id).Error return app.Http.Database.DB.Delete(&CuentaCobro{}, id).Error
} }
@@ -620,18 +494,8 @@ 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) {
// Inicializado para que sin filas el JSON sea [] y no null: null obliga var items []CuentaPagar
// 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 != "" {
@@ -669,38 +533,6 @@ func UpdateCuentaPagar(cp *CuentaPagar) error {
}).Error }).Error
} }
// MarcarCuentaPagarPagada registra el pago de una cuenta por pagar: crea la
// Transaccion de tipo "egreso" correspondiente y la vincula. Es idempotente —
// si la cuenta ya estaba pagada, no crea una transacción duplicada.
func MarcarCuentaPagarPagada(id uint, fechaPago time.Time) error {
var cp CuentaPagar
if err := app.Http.Database.DB.Preload("Entidad").First(&cp, id).Error; err != nil {
return err
}
if cp.Estado == "pagado" {
return nil
}
desc := fmt.Sprintf("Pago cuenta por pagar #%d: %s", cp.ID, cp.Descripcion)
if cp.Entidad.Nombre != "" {
desc = fmt.Sprintf("Pago cuenta por pagar #%d (%s): %s", cp.ID, cp.Entidad.Nombre, cp.Descripcion)
}
t := &Transaccion{
Fecha: fechaPago,
Tipo: "egreso",
Descripcion: desc,
Valor: cp.Valor,
EntidadID: &cp.EntidadID,
}
if err := CreateTransaccion(t); err != nil {
return fmt.Errorf("no se pudo crear la transacción de pago: %w", err)
}
return app.Http.Database.DB.Model(&CuentaPagar{}).Where("id = ?", id).Updates(map[string]interface{}{
"estado": "pagado",
"fecha_pago": fechaPago,
"transaccion_id": t.ID,
}).Error
}
func DeleteCuentaPagar(id uint) error { func DeleteCuentaPagar(id uint) error {
return app.Http.Database.DB.Delete(&CuentaPagar{}, id).Error return app.Http.Database.DB.Delete(&CuentaPagar{}, id).Error
} }
@@ -764,9 +596,7 @@ func CalcularYGuardarConsolidado(mes, anio int) (*ConsolidadoMensual, error) {
} }
func ListConsolidados(anio int) ([]ConsolidadoMensual, error) { func ListConsolidados(anio int) ([]ConsolidadoMensual, error) {
// Inicializado para que sin filas el JSON sea [] y no null: null obliga var items []ConsolidadoMensual
// 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)
@@ -850,13 +680,14 @@ func SeedContabilidad() {
} }
entidades := []Entidad{ entidades := []Entidad{
{Nombre: "DOCUXER", Tipo: "cliente"}, {Nombre: "ANDREMER", Tipo: "cliente"},
{Nombre: "GIAF SAS", Tipo: "cliente"}, {Nombre: "GIAF SAS", Tipo: "cliente"},
{Nombre: "TECZONE", Tipo: "proveedor"}, {Nombre: "TECZONE", Tipo: "cliente"},
{Nombre: "FELIPE", Tipo: "proveedor"}, {Nombre: "DOCUXER", Tipo: "cliente"},
{Nombre: "NATALIA", Tipo: "proveedor"}, {Nombre: "FELIPE", Tipo: "cliente"},
{Nombre: "CONTADORA", Tipo: "proveedor"}, {Nombre: "NATALIA", Tipo: "cliente"},
{Nombre: "ANDREMER", Tipo: "proveedor"}, {Nombre: "CONTADORA", Tipo: "cliente"},
{Nombre: "ANDREMER", Tipo: "cliente"},
} }
for _, e := range entidades { for _, e := range entidades {
var existing Entidad var existing Entidad
+2 -54
View File
@@ -2,7 +2,6 @@ package models
import ( import (
"log" "log"
"strings"
"time" "time"
"github.com/sujit-baniya/fiber-boilerplate/app" "github.com/sujit-baniya/fiber-boilerplate/app"
@@ -156,11 +155,8 @@ func syncContratoJoin(db *gorm.DB, table string, contratoID uint, ids []uint) er
if err := db.Exec("DELETE FROM "+table+" WHERE contrato_id = ?", contratoID).Error; err != nil { if err := db.Exec("DELETE FROM "+table+" WHERE contrato_id = ?", contratoID).Error; err != nil {
return err return err
} }
// Derive FK column: "contrato_servidores" → "servidor_id", "contrato_conx_dbs" → "conx_db_id"
entityPlural := strings.TrimPrefix(table, "contrato_")
fkCol := strings.TrimSuffix(entityPlural, "s") + "_id"
for _, id := range ids { for _, id := range ids {
if err := db.Exec("INSERT INTO "+table+" (contrato_id, "+fkCol+") VALUES (?, ?) ON CONFLICT DO NOTHING", if err := db.Exec("INSERT INTO "+table+" (contrato_id, "+table[:len(table)-1]+"_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
contratoID, id, contratoID, id,
).Error; err != nil { ).Error; err != nil {
return err return err
@@ -236,42 +232,14 @@ func DeleteContrato(id uint) error {
return db.Delete(&c).Error return db.Delete(&c).Error
} }
// GuardarEnlacePago persiste el enlace de cobro de un ciclo en el contrato. // GuardarEnlacePago persiste el enlace de pago Bold 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 {
@@ -361,26 +329,6 @@ 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
}
// ConfirmarPagoManual marca el pago como confirmado sin modificar estado ni fecha_vencimiento.
// Útil cuando el admin ya ajustó manualmente el contrato y solo necesita limpiar el enlace pendiente.
func ConfirmarPagoManual(contratoID uint) (bool, error) {
now := time.Now()
result := app.Http.Database.DB.Model(&Contrato{}).
Where("id = ? AND pago_confirmado = false", contratoID).
Updates(map[string]interface{}{
"pago_confirmado": true,
"fecha_pago": now,
"enlace_pago": "",
"enlace_pago_link_id": "",
})
return result.RowsAffected > 0, result.Error return result.RowsAffected > 0, result.Error
} }
+8 -48
View File
@@ -17,65 +17,25 @@ type CoolifyConfig struct {
func (CoolifyConfig) TableName() string { return "coolify_configs" } func (CoolifyConfig) TableName() string { return "coolify_configs" }
// GetCoolifyConfig retorna la primera instancia activa (compatibilidad legacy).
func GetCoolifyConfig() (*CoolifyConfig, error) { func GetCoolifyConfig() (*CoolifyConfig, error) {
var cfg CoolifyConfig var cfg CoolifyConfig
if err := app.Http.Database.DB.Where("activo = ?", true).First(&cfg).Error; err != nil { if err := app.Http.Database.DB.First(&cfg).Error; err != nil {
// fallback: cualquier registro
if err2 := app.Http.Database.DB.First(&cfg).Error; err2 != nil {
return nil, err2
}
}
return &cfg, nil
}
// GetCoolifyConfigByID retorna una instancia específica.
func GetCoolifyConfigByID(id uint) (*CoolifyConfig, error) {
var cfg CoolifyConfig
if err := app.Http.Database.DB.First(&cfg, id).Error; err != nil {
return nil, err return nil, err
} }
return &cfg, nil return &cfg, nil
} }
// GetAllCoolifyConfigs retorna todas las instancias.
func GetAllCoolifyConfigs() ([]CoolifyConfig, error) {
var items []CoolifyConfig
err := app.Http.Database.DB.Order("id ASC").Find(&items).Error
return items, err
}
func CreateCoolifyConfig(cfg *CoolifyConfig) error {
return app.Http.Database.DB.Create(cfg).Error
}
func UpdateCoolifyConfig(id uint, nombre, baseURL, apiToken string, activo bool) (*CoolifyConfig, error) {
var cfg CoolifyConfig
if err := app.Http.Database.DB.First(&cfg, id).Error; err != nil {
return nil, err
}
cfg.Nombre = nombre
cfg.BaseURL = baseURL
if apiToken != "" {
cfg.ApiToken = apiToken
}
cfg.Activo = activo
if err := app.Http.Database.DB.Save(&cfg).Error; err != nil {
return nil, err
}
return &cfg, nil
}
func DeleteCoolifyConfig(id uint) error {
return app.Http.Database.DB.Delete(&CoolifyConfig{}, id).Error
}
// UpsertCoolifyConfig mantiene compatibilidad con código legado.
func UpsertCoolifyConfig(nombre, baseURL, apiToken string, activo bool) (*CoolifyConfig, error) { func UpsertCoolifyConfig(nombre, baseURL, apiToken string, activo bool) (*CoolifyConfig, error) {
var cfg CoolifyConfig var cfg CoolifyConfig
err := app.Http.Database.DB.First(&cfg).Error err := app.Http.Database.DB.First(&cfg).Error
if err != nil { if err != nil {
cfg = CoolifyConfig{Nombre: nombre, BaseURL: baseURL, ApiToken: apiToken, Activo: activo} // No existe → crear
cfg = CoolifyConfig{
Nombre: nombre,
BaseURL: baseURL,
ApiToken: apiToken,
Activo: activo,
}
if createErr := app.Http.Database.DB.Create(&cfg).Error; createErr != nil { if createErr := app.Http.Database.DB.Create(&cfg).Error; createErr != nil {
return nil, createErr return nil, createErr
} }
-57
View File
@@ -1,57 +0,0 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// DocumentoGenerado es el historial de documentos producidos por el motor de
// automatización con IA (cotización, contrato, acta/arquitectura, cuenta de cobro),
// sin importar el canal que los originó (Telegram, chat propio o Claude directo).
type DocumentoGenerado struct {
gorm.Model
Tipo string `json:"tipo" gorm:"column:tipo;size:30;not null;index"` // cotizacion | contrato | acta | cuenta_cobro
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
Cliente *Cliente `json:"cliente,omitempty" gorm:"foreignKey:ClienteID"`
ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
PlantillaID *uint `json:"plantilla_id" gorm:"column:plantilla_id"`
Nombre string `json:"nombre" gorm:"column:nombre;size:200"`
Archivo string `json:"archivo" gorm:"column:archivo"` // ruta en disco, ej: uploads/documentos/cotizacion/12/xyz.pdf
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime;default:'application/pdf'"`
Tamanio int64 `json:"tamanio" gorm:"column:tamanio"`
DatosJSON string `json:"datos_json" gorm:"column:datos_json;type:text"` // input usado para generarlo (auditoría)
GeneradoPor string `json:"generado_por" gorm:"column:generado_por;size:20;default:'web'"` // web | telegram | claude_api
}
func (DocumentoGenerado) TableName() string { return "documentos_generados" }
func CreateDocumentoGenerado(d *DocumentoGenerado) error {
return app.Http.Database.DB.Create(d).Error
}
func GetAllDocumentosGenerados(limit, offset int, tipo string, clienteID uint) ([]DocumentoGenerado, int64, error) {
var items []DocumentoGenerado
var total int64
db := app.Http.Database.DB.Model(&DocumentoGenerado{}).Preload("Cliente")
if tipo != "" {
db = db.Where("tipo = ?", tipo)
}
if clienteID != 0 {
db = db.Where("cliente_id = ?", clienteID)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func GetDocumentoGeneradoByID(id uint) (*DocumentoGenerado, error) {
var item DocumentoGenerado
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
-1
View File
@@ -11,7 +11,6 @@ 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_nuevo → admin recibe cuando el cliente abre un ticket
// ticket_respuesta_cliente → admin recibe cuando el cliente responde // ticket_respuesta_cliente → admin recibe cuando el cliente responde
// ticket_respuesta_admin → portal_user recibe cuando admin responde // ticket_respuesta_admin → portal_user recibe cuando admin responde
-41
View File
@@ -1,41 +0,0 @@
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
}
+1 -1
View File
@@ -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
} }
-298
View File
@@ -1,298 +0,0 @@
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
}
-129
View File
@@ -1,129 +0,0 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// PlantillaDocumento es la fuente de verdad de las plantillas base usadas por
// la automatización con IA: cotización, contrato, acta de proyecto y cuenta de cobro.
// Editar una plantilla aquí actualiza automáticamente todos los canales (Telegram,
// chat propio, Claude directo) que generan ese tipo de documento.
type PlantillaDocumento struct {
gorm.Model
Tipo string `json:"tipo" gorm:"column:tipo;size:30;not null;index"` // cotizacion | contrato | acta | cuenta_cobro
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
ContenidoHTML string `json:"contenido_html" gorm:"column:contenido_html;type:text"` // Go text/template
Version int `json:"version" gorm:"column:version;default:1"`
Activa bool `json:"activa" gorm:"column:activa;default:true"`
Notas string `json:"notas" gorm:"column:notas;type:text"`
// 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 GetAllPlantillasDocumento(limit, offset int, search, tipo string) ([]PlantillaDocumento, int64, error) {
var items []PlantillaDocumento
var total int64
db := app.Http.Database.DB.Model(&PlantillaDocumento{})
if search != "" {
db = db.Where("nombre ILIKE ?", "%"+search+"%")
}
if tipo != "" {
db = db.Where("tipo = ?", tipo)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("tipo ASC, version DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func GetPlantillaDocumentoByID(id uint) (*PlantillaDocumento, error) {
var item PlantillaDocumento
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
// GetPlantillaDocumentoActiva retorna la plantilla activa más reciente para un tipo dado.
// Es la que usan los endpoints de generación (cotizaciones, contratos, etc).
// 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) {
var item PlantillaDocumento
if err := app.Http.Database.DB.
Where("tipo = ? AND activa = ? AND tenant_id IS NULL", tipo, true).
Order("version DESC").
First(&item).Error; err != nil {
return nil, err
}
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 {
return app.Http.Database.DB.Create(&p).Error
}
func UpdatePlantillaDocumento(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&PlantillaDocumento{}).Where("id = ?", id).Updates(updates).Error
}
func DeletePlantillaDocumento(id uint) error {
return app.Http.Database.DB.Delete(&PlantillaDocumento{}, id).Error
}
// 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
}
+4 -19
View File
@@ -181,27 +181,12 @@ 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.
// EsPartner es la ÚNICA definición de "este usuario es partner". Existe
// porque el rol se guarda en dos lados (la columna Rol y el flag
// EsPortalPartner del Role) y consultarlos por separado se contradice: el
// dashboard decidía el layout con u.Rol y los proyectos con Role, así que un
// usuario con los dos desincronizados veía proyectos de varios clientes sin
// agrupar, o la vista agrupada vacía.
func (u *PortalUser) EsPartner() bool {
if u == nil {
return false
}
if u.Role != nil {
return u.Role.EsPortalPartner
}
return u.Rol == "partner"
}
func GetClienteIDsForPortalUser(u *PortalUser) []uint { func GetClienteIDsForPortalUser(u *PortalUser) []uint {
if u == nil { isPartner := u.Rol == "partner"
return []uint{} if u.Role != nil {
isPartner = u.Role.EsPortalPartner
} }
if u.EsPartner() { if isPartner {
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)
-62
View File
@@ -1,62 +0,0 @@
package models
import "testing"
// El rol vive en dos lados (la columna Rol y el flag EsPortalPartner del
// Role). Consultarlos por separado se contradecía: el dashboard elegía el
// layout con uno y los proyectos con el otro. EsPartner es ahora la única
// definición, y estos casos fijan cuál gana.
func TestEsPartner(t *testing.T) {
casos := []struct {
nombre string
user *PortalUser
esperado bool
}{
{"nil no es partner", nil, false},
{"sin Role, rol partner", &PortalUser{Rol: "partner"}, true},
{"sin Role, rol cliente", &PortalUser{Rol: "cliente"}, false},
{"con Role, el flag manda aunque el rol diga cliente",
&PortalUser{Rol: "cliente", Role: &Roles{EsPortalPartner: true}}, true},
{"con Role, el flag manda aunque el rol diga partner",
&PortalUser{Rol: "partner", Role: &Roles{EsPortalPartner: false}}, false},
}
for _, cas := range casos {
t.Run(cas.nombre, func(t *testing.T) {
if got := cas.user.EsPartner(); got != cas.esperado {
t.Errorf("EsPartner() = %v, esperaba %v", got, cas.esperado)
}
})
}
}
// El alcance del portal se deriva de EsPartner: un desajuste acá es que un
// usuario vea proyectos de un cliente que no le corresponde.
func TestGetClienteIDsForPortalUser(t *testing.T) {
cid := uint(7)
casos := []struct {
nombre string
user *PortalUser
esperado []uint
}{
{"nil no ve nada", nil, []uint{}},
{"cliente ve el suyo", &PortalUser{Rol: "cliente", ClienteID: &cid}, []uint{7}},
{"cliente sin ClienteID no ve nada", &PortalUser{Rol: "cliente"}, []uint{}},
{"partner ve los de sus accesos",
&PortalUser{Rol: "partner", PortalAccesos: []PortalAcceso{{ClienteID: 3}, {ClienteID: 9}}},
[]uint{3, 9}},
{"partner sin accesos no ve nada", &PortalUser{Rol: "partner"}, []uint{}},
}
for _, cas := range casos {
t.Run(cas.nombre, func(t *testing.T) {
got := GetClienteIDsForPortalUser(cas.user)
if len(got) != len(cas.esperado) {
t.Fatalf("= %v, esperaba %v", got, cas.esperado)
}
for i := range got {
if got[i] != cas.esperado[i] {
t.Errorf("= %v, esperaba %v", got, cas.esperado)
}
}
})
}
}
-50
View File
@@ -327,53 +327,3 @@ 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]
}
}
+6 -84
View File
@@ -9,25 +9,13 @@ 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"`
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
AsignadoA *uint `json:"asignado_a" gorm:"column:asignado_a;index"`
Asignado *Users `json:"asignado" gorm:"foreignKey:AsignadoA"`
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo que originó el ticket (dedup)
// ClienteID se resuelve al crear el ticket a partir del remitente. Si queda
// en nil el que escribió no está registrado: es un contacto externo, y eso
// también es información (no hay un campo aparte para "externo", es esto).
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
Cliente *Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
// Categoria la pone el clasificador: error | facturacion | acceso | consulta | otro
Categoria string `json:"categoria" gorm:"column:categoria;size:40;index"`
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"` Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
} }
@@ -42,7 +30,6 @@ type TicketMensaje struct {
EsAdmin bool `json:"es_admin" gorm:"column:es_admin;default:false"` EsAdmin bool `json:"es_admin" gorm:"column:es_admin;default:false"`
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"` AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
LeidoPortal bool `json:"leido_portal" gorm:"column:leido_portal;default:false"` LeidoPortal bool `json:"leido_portal" gorm:"column:leido_portal;default:false"`
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo de respuesta (dedup)
} }
func (TicketMensaje) TableName() string { return "ticket_mensajes" } func (TicketMensaje) TableName() string { return "ticket_mensajes" }
@@ -52,13 +39,13 @@ func (TicketMensaje) TableName() string { return "ticket_mensajes" }
func GetTicketsByProyecto(proyectoID uint) ([]ProyectoTicket, error) { func GetTicketsByProyecto(proyectoID uint) ([]ProyectoTicket, error) {
var items []ProyectoTicket var items []ProyectoTicket
err := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID). err := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID).
Preload("Mensajes").Preload("Asignado").Order("created_at DESC").Find(&items).Error Preload("Mensajes").Order("created_at DESC").Find(&items).Error
return items, err return items, err
} }
func GetTicketByID(id uint) (*ProyectoTicket, error) { func GetTicketByID(id uint) (*ProyectoTicket, error) {
var item ProyectoTicket var item ProyectoTicket
err := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").First(&item, id).Error err := app.Http.Database.DB.Preload("Mensajes").First(&item, id).Error
return &item, err return &item, err
} }
@@ -75,44 +62,16 @@ func CreateTicketMensaje(m *TicketMensaje) error {
return app.Http.Database.DB.Create(m).Error return app.Http.Database.DB.Create(m).Error
} }
// EmailMessageIDYaProcesado indica si un Message-Id de correo ya generó un ticket
// o un mensaje de ticket, para no duplicar cuando el proveedor reintenta la entrega.
func EmailMessageIDYaProcesado(messageID string) bool {
if messageID == "" {
return false
}
var count int64
app.Http.Database.DB.Model(&ProyectoTicket{}).Where("message_id = ?", messageID).Count(&count)
if count > 0 {
return true
}
app.Http.Database.DB.Model(&TicketMensaje{}).Where("message_id = ?", messageID).Count(&count)
return count > 0
}
// GetUltimoTicketAbiertoPorEmail busca el ticket de origen email más reciente y no
// cerrado de un remitente, para enhebrar una respuesta en vez de abrir uno nuevo.
func GetUltimoTicketAbiertoPorEmail(email string) (*ProyectoTicket, error) {
var t ProyectoTicket
err := app.Http.Database.DB.
Where("email_from = ? AND origen = ? AND estado <> ?", email, "email", "cerrado").
Order("created_at DESC").First(&t).Error
if err != nil {
return nil, err
}
return &t, nil
}
func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) { func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) {
var items []ProyectoTicket var items []ProyectoTicket
err := app.Http.Database.DB.Where("portal_user_id = ?", portalUserID). err := app.Http.Database.DB.Where("portal_user_id = ?", portalUserID).
Preload("Mensajes").Preload("Asignado").Order("created_at DESC").Find(&items).Error Preload("Mensajes").Order("created_at DESC").Find(&items).Error
return items, err return items, err
} }
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").Preload("Cliente").Order("created_at DESC") db := app.Http.Database.DB.Preload("Mensajes").Order("created_at DESC")
if estado != "" && estado != "todos" { if estado != "" && estado != "todos" {
db = db.Where("estado = ?", estado) db = db.Where("estado = ?", estado)
} }
@@ -120,28 +79,6 @@ func GetAllTickets(estado string) ([]ProyectoTicket, error) {
return items, err return items, err
} }
func AssignedToUser(userID uint) error {
return app.Http.Database.DB.Model(&ProyectoTicket{}).
Where("asignado_a IS NULL OR asignado_a = 0").
Update("asignado_a", userID).Error
}
func CountTicketsByEstado() map[string]int64 {
result := map[string]int64{}
type row struct {
Estado string
Count int64
}
var rows []row
app.Http.Database.DB.Model(&ProyectoTicket{}).
Select("estado, count(*) as count").
Group("estado").Find(&rows)
for _, r := range rows {
result[r.Estado] = r.Count
}
return result
}
// MarkTicketMessagesReadByPortal marca como leídos todos los mensajes de admin // MarkTicketMessagesReadByPortal marca como leídos todos los mensajes de admin
// de un ticket para el portal user (cuando abre el ticket). // de un ticket para el portal user (cuando abre el ticket).
func MarkTicketMessagesReadByPortal(ticketID uint) error { func MarkTicketMessagesReadByPortal(ticketID uint) error {
@@ -149,18 +86,3 @@ 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
}
-91
View File
@@ -1,91 +0,0 @@
package models
import (
"testing"
)
func TestProyectoTicketTableName(t *testing.T) {
if got := (ProyectoTicket{}).TableName(); got != "proyecto_tickets" {
t.Errorf("TableName() = %q, want %q", got, "proyecto_tickets")
}
}
func TestTicketMensajeTableName(t *testing.T) {
if got := (TicketMensaje{}).TableName(); got != "ticket_mensajes" {
t.Errorf("TableName() = %q, want %q", got, "ticket_mensajes")
}
}
func TestProyectoTicketDefaults(t *testing.T) {
ticket := ProyectoTicket{
Titulo: "Test ticket",
Descripcion: "Descripción",
AutorNombre: "Cliente",
}
if ticket.Estado != "" {
t.Errorf("Estado debe ser vacío por defecto (GORM default), got %q", ticket.Estado)
}
if ticket.Prioridad != "" {
t.Errorf("Prioridad debe ser vacía por defecto (GORM default), got %q", ticket.Prioridad)
}
if ticket.Origen != "" {
t.Errorf("Origen debe ser vacío por defecto (GORM default), got %q", ticket.Origen)
}
if ticket.ProyectoID != nil {
t.Errorf("ProyectoID debe ser nil por defecto, got %v", ticket.ProyectoID)
}
if ticket.PortalUserID != nil {
t.Errorf("PortalUserID debe ser nil por defecto, got %v", ticket.PortalUserID)
}
}
func TestProyectoTicketEmailFrom(t *testing.T) {
ticket := ProyectoTicket{
EmailFrom: "cliente@ejemplo.com",
AutorNombre: "Cliente",
Titulo: "Soporte",
Origen: "email",
}
if ticket.EmailFrom != "cliente@ejemplo.com" {
t.Errorf("EmailFrom = %q, want %q", ticket.EmailFrom, "cliente@ejemplo.com")
}
if ticket.Origen != "email" {
t.Errorf("Origen = %q, want %q", ticket.Origen, "email")
}
}
func TestProyectoTicketAsignado(t *testing.T) {
uid := uint(42)
ticket := ProyectoTicket{
AsignadoA: &uid,
}
if ticket.AsignadoA == nil {
t.Fatal("AsignadoA debe ser no nil")
}
if *ticket.AsignadoA != 42 {
t.Errorf("AsignadoA = %d, want %d", *ticket.AsignadoA, 42)
}
}
func TestProyectoTicketAsignadoNil(t *testing.T) {
ticket := ProyectoTicket{}
if ticket.AsignadoA != nil {
t.Errorf("AsignadoA debe ser nil por defecto, got %v", *ticket.AsignadoA)
}
}
func TestTicketMensajeDefaults(t *testing.T) {
msg := TicketMensaje{
TicketID: 1,
Contenido: "Hola",
AutorNombre: "Admin",
}
if msg.EsAdmin {
t.Error("EsAdmin debe ser false por defecto")
}
if msg.LeidoPortal {
t.Error("LeidoPortal debe ser false por defecto")
}
}
// CountTicketsByEstado requiere DB real — probado en integration tests
+4 -20
View File
@@ -12,7 +12,6 @@ type QueryHistory struct {
gorm.Model gorm.Model
ConxDbID uint `json:"conx_db_id" gorm:"column:conx_db_id;index"` ConxDbID uint `json:"conx_db_id" gorm:"column:conx_db_id;index"`
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"`
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"`
@@ -30,23 +29,8 @@ func SaveQueryHistory(h QueryHistory) error {
return app.Http.Database.DB.Create(&h).Error return app.Http.Database.DB.Create(&h).Error
} }
// GetQueryHistory devuelve el historial de una conexión con paginación, filtrado por usuario. // GetQueryHistory devuelve el historial de una conexión con paginación.
func GetQueryHistory(conxDbID, userID uint, limit, offset int) ([]QueryHistory, int64, error) { func GetQueryHistory(conxDbID uint, limit, offset int) ([]QueryHistory, int64, error) {
var items []QueryHistory
var total int64
db := app.Http.Database.DB.Model(&QueryHistory{}).Where("conx_db_id = ? AND user_id = ?", conxDbID, userID)
db.Count(&total)
err := db.Order("executed_at DESC").Limit(limit).Offset(offset).Find(&items).Error
return items, total, err
}
// DeleteQueryHistory elimina el historial de una conexión para un usuario.
func DeleteQueryHistory(conxDbID, userID uint) error {
return app.Http.Database.DB.Where("conx_db_id = ? AND user_id = ?", conxDbID, userID).Delete(&QueryHistory{}).Error
}
// GetQueryHistoryAdmin devuelve todo el historial de una conexión (solo admin).
func GetQueryHistoryAdmin(conxDbID uint, limit, offset int) ([]QueryHistory, int64, error) {
var items []QueryHistory var items []QueryHistory
var total int64 var total int64
db := app.Http.Database.DB.Model(&QueryHistory{}).Where("conx_db_id = ?", conxDbID) db := app.Http.Database.DB.Model(&QueryHistory{}).Where("conx_db_id = ?", conxDbID)
@@ -55,7 +39,7 @@ func GetQueryHistoryAdmin(conxDbID uint, limit, offset int) ([]QueryHistory, int
return items, total, err return items, total, err
} }
// DeleteQueryHistoryAdmin elimina todo el historial de una conexión (solo admin). // DeleteQueryHistory elimina todo el historial de una conexión.
func DeleteQueryHistoryAdmin(conxDbID uint) error { func DeleteQueryHistory(conxDbID uint) error {
return app.Http.Database.DB.Where("conx_db_id = ?", conxDbID).Delete(&QueryHistory{}).Error return app.Http.Database.DB.Where("conx_db_id = ?", conxDbID).Delete(&QueryHistory{}).Error
} }
+1 -11
View File
@@ -13,11 +13,9 @@ type Roles struct {
CreatedAt time.Time `json:"created_at" gorm:"column:created_at"` CreatedAt time.Time `json:"created_at" gorm:"column:created_at"`
Description string `json:"description" gorm:"column:description"` Description string `json:"description" gorm:"column:description"`
Name string `json:"name" gorm:"column:name"` Name string `json:"name" gorm:"column:name"`
HomeUrl string `json:"home_url" gorm:"column:home_url"`
EsPortalCliente bool `json:"es_portal_cliente" gorm:"column:es_portal_cliente;default:false"` EsPortalCliente bool `json:"es_portal_cliente" gorm:"column:es_portal_cliente;default:false"`
EsPortalPartner bool `json:"es_portal_partner" gorm:"column:es_portal_partner;default:false"` EsPortalPartner bool `json:"es_portal_partner" gorm:"column:es_portal_partner;default:false"`
Submodules []Submodules `json:"submodules" gorm:"many2many:roles_submodules"` Submodules []Submodules `json:"submodules" gorm:"many2many:roles_submodules"`
ConxDBs []ConxDb `json:"conx_dbs" gorm:"many2many:roles_conx_db"`
} }
// TableName overrides the table name used by Modules to `modules` // TableName overrides the table name used by Modules to `modules`
@@ -42,7 +40,7 @@ func AllRoles(limit, offset int, search string) ([]Roles, int64, error) {
} }
// Obtener los módulos con paginación // Obtener los módulos con paginación
if err := db.Order("id DESC").Limit(limit).Offset(offset).Preload("Submodules").Preload("ConxDBs").Find(&roles).Error; err != nil { if err := db.Order("id DESC").Limit(limit).Offset(offset).Preload("Submodules").Find(&roles).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -63,14 +61,6 @@ func AllRolesSelect() ([]Roles, error) {
// CreateModule creates a new rol // CreateModule creates a new rol
func CreateRole(role Roles) error { func CreateRole(role Roles) error {
var conxDbs []ConxDb
for _, db := range role.ConxDBs {
var found ConxDb
if err := app.Http.Database.DB.First(&found, db.ID).Error; err == nil {
conxDbs = append(conxDbs, found)
}
}
role.ConxDBs = conxDbs
if err := app.Http.Database.DB.Create(&role).Error; err != nil { if err := app.Http.Database.DB.Create(&role).Error; err != nil {
return err return err
} }
-46
View File
@@ -103,49 +103,3 @@ func DeleteServidor(servidor Servidor) error {
} }
return nil return nil
} }
// ── Historial de métricas ─────────────────────────────────────────────────────
type ServidorMetricasHistory struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
ServidorID uint `json:"servidor_id" gorm:"column:servidor_id;index"`
CreatedAt time.Time `json:"created_at" gorm:"column:created_at;index"`
CpuPct float64 `json:"cpu_pct" gorm:"column:cpu_pct"`
RamPct float64 `json:"ram_pct" gorm:"column:ram_pct"`
SwapPct float64 `json:"swap_pct" gorm:"column:swap_pct"`
DiscoPct float64 `json:"disco_pct" gorm:"column:disco_pct"`
TCPConns int `json:"tcp_conns" gorm:"column:tcp_conns"`
Load1 float64 `json:"load1" gorm:"column:load1"`
}
func (ServidorMetricasHistory) TableName() string { return "servidor_metricas_history" }
func InsertMetricasHistory(servidorID uint, cpu, ram, swap, disco float64, tcp int, load1 float64) {
app.Http.Database.DB.Create(&ServidorMetricasHistory{
ServidorID: servidorID,
CreatedAt: time.Now(),
CpuPct: cpu,
RamPct: ram,
SwapPct: swap,
DiscoPct: disco,
TCPConns: tcp,
Load1: load1,
})
}
func GetMetricasHistory(servidorID uint, horas int) ([]ServidorMetricasHistory, error) {
var items []ServidorMetricasHistory
desde := time.Now().Add(-time.Duration(horas) * time.Hour)
err := app.Http.Database.DB.
Where("servidor_id = ? AND created_at >= ?", servidorID, desde).
Order("created_at ASC").
Find(&items).Error
return items, err
}
func PurgarMetricasHistory(dias int) error {
corte := time.Now().AddDate(0, 0, -dias)
return app.Http.Database.DB.
Where("created_at < ?", corte).
Delete(&ServidorMetricasHistory{}).Error
}
-82
View File
@@ -1,82 +0,0 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
type SoporteWebhookConfig struct {
gorm.Model
Activo bool `json:"activo" gorm:"column:activo;default:true"`
Nombre string `json:"nombre" gorm:"column:nombre;size:100"`
Provider string `json:"provider" gorm:"column:provider;size:50;default:'sendgrid'"` // sendgrid|mailgun|generic
ApiKey string `json:"api_key" gorm:"column:api_key;size:255"`
EmailDestino string `json:"email_destino" gorm:"column:email_destino;size:255"` // ej: soporte@u-s.app
ResponderAuto bool `json:"responder_auto" gorm:"column:responder_auto;default:true"`
MensajeAuto string `json:"mensaje_auto" gorm:"column:mensaje_auto;type:text"`
AsignarA *uint `json:"asignar_a" gorm:"column:asignar_a;index"` // auto-asignar tickets a este user
// SMTP salida para notificaciones y auto-respuesta
SmtpHost string `json:"smtp_host" gorm:"column:smtp_host;size:255"`
SmtpPort int `json:"smtp_port" gorm:"column:smtp_port;default:587"`
SmtpUsername string `json:"smtp_username" gorm:"column:smtp_username;size:255"`
SmtpPassword string `json:"smtp_password" gorm:"column:smtp_password;size:255"` // cifrado AES
SmtpEncryption string `json:"smtp_encryption" gorm:"column:smtp_encryption;size:20;default:'tls'"` // tls|starttls|none
SmtpFromAddr string `json:"smtp_from_addr" gorm:"column:smtp_from_addr;size:255"`
SmtpFromName string `json:"smtp_from_name" gorm:"column:smtp_from_name;size:255"`
// IMAP entrante: leer el buzón directamente en vez de depender de que un
// proveedor nos haga POST. Es lo único que hace falta para responderle a un
// cliente que escribe a soporte@ desde su correo de siempre.
ImapActivo bool `json:"imap_activo" gorm:"column:imap_activo;default:false"`
ImapHost string `json:"imap_host" gorm:"column:imap_host;size:255"`
ImapPort int `json:"imap_port" gorm:"column:imap_port;default:993"`
ImapUsername string `json:"imap_username" gorm:"column:imap_username;size:255"`
ImapPasswordEnc string `json:"-" gorm:"column:imap_password_enc;type:text"` // AES-GCM con APP_KEY
ImapEncryption string `json:"imap_encryption" gorm:"column:imap_encryption;size:20;default:'ssl'"` // ssl|starttls
ImapCarpeta string `json:"imap_carpeta" gorm:"column:imap_carpeta;size:100;default:'INBOX'"`
// Ventana hacia atrás, en horas: solo se leen los correos recibidos dentro
// de ella. 0 = sin límite (todo el buzón sin leer). Es lo que evita que la
// primera corrida se coma años de correo viejo.
ImapHorasAtras int `json:"imap_horas_atras" gorm:"column:imap_horas_atras;default:12"`
// Filtro con IA: no todo lo que llega al buzón es soporte (newsletters,
// notificaciones de bancos, facturas de proveedores). Si está prendido, se
// clasifica cada correo nuevo antes de abrir ticket.
ClasificarConIA bool `json:"clasificar_con_ia" gorm:"column:clasificar_con_ia;default:false"`
ContextoNegocio string `json:"contexto_negocio" gorm:"column:contexto_negocio;type:text"`
// Agente de uMind cuya base de conocimiento se usa para redactar borradores
// de respuesta. Sin agente el borrador se arma solo con la conversación.
AgenteBorradorID *uint `json:"agente_borrador_id" gorm:"column:agente_borrador_id;index"`
// Solo para la vista: dice si ya hay contraseña guardada sin exponerla, para
// que el formulario sepa que puede mandar el campo vacío sin borrarla.
TieneImapPassword bool `json:"tiene_imap_password" gorm:"-"`
}
func (SoporteWebhookConfig) TableName() string { return "soporte_webhook_config" }
func GetSoporteWebhookActivo() (*SoporteWebhookConfig, error) {
var item SoporteWebhookConfig
err := app.Http.Database.DB.Where("activo = ?", true).First(&item).Error
return &item, err
}
func GetAllSoporteWebhookConfigs() ([]SoporteWebhookConfig, error) {
var items []SoporteWebhookConfig
err := app.Http.Database.DB.Order("created_at DESC").Find(&items).Error
return items, err
}
func SaveSoporteWebhookConfig(s *SoporteWebhookConfig) error {
if s.ID > 0 {
// Select("*") para que los booleanos en false (activo, responder_auto,
// imap_activo) también se guarden: Updates con struct ignora los ceros,
// así que desactivar algo no tenía efecto.
return app.Http.Database.DB.Model(&SoporteWebhookConfig{}).
Where("id = ?", s.ID).
Select("*").Omit("id", "created_at", "deleted_at").
Updates(s).Error
}
return app.Http.Database.DB.Create(s).Error
}
-69
View File
@@ -1,69 +0,0 @@
package models
import (
"testing"
)
func TestSoporteWebhookConfigTableName(t *testing.T) {
if got := (SoporteWebhookConfig{}).TableName(); got != "soporte_webhook_config" {
t.Errorf("TableName() = %q, want %q", got, "soporte_webhook_config")
}
}
func TestSoporteWebhookConfigDefaults(t *testing.T) {
cfg := SoporteWebhookConfig{
Nombre: "Test",
Provider: "sendgrid",
}
if cfg.Provider != "sendgrid" {
t.Errorf("Provider = %q, want %q", cfg.Provider, "sendgrid")
}
if cfg.EmailDestino != "" {
t.Errorf("EmailDestino debe ser vacío, got %q", cfg.EmailDestino)
}
}
func TestSoporteWebhookConfigAutoResponder(t *testing.T) {
cfg := SoporteWebhookConfig{
Nombre: "Soporte",
ResponderAuto: true,
MensajeAuto: "Gracias por contactarnos",
}
if !cfg.ResponderAuto {
t.Error("ResponderAuto debe ser true")
}
if cfg.MensajeAuto != "Gracias por contactarnos" {
t.Errorf("MensajeAuto = %q, want %q", cfg.MensajeAuto, "Gracias por contactarnos")
}
}
func TestSoporteWebhookConfigAsignarA(t *testing.T) {
uid := uint(5)
cfg := SoporteWebhookConfig{
AsignarA: &uid,
}
if cfg.AsignarA == nil {
t.Fatal("AsignarA debe ser no nil")
}
if *cfg.AsignarA != 5 {
t.Errorf("AsignarA = %d, want %d", *cfg.AsignarA, 5)
}
}
func TestSoporteWebhookConfigAsignarANil(t *testing.T) {
cfg := SoporteWebhookConfig{}
if cfg.AsignarA != nil {
t.Errorf("AsignarA debe ser nil por defecto, got %v", *cfg.AsignarA)
}
}
func TestSoporteWebhookConfigSmtpDefaults(t *testing.T) {
cfg := SoporteWebhookConfig{Nombre: "SMTP Test"}
// Go zero values — los defaults reales los aplica GORM o el controller
if cfg.SmtpHost != "" {
t.Errorf("SmtpHost debe ser vacío por defecto, got %q", cfg.SmtpHost)
}
if cfg.SmtpPort != 0 {
t.Errorf("SmtpPort debe ser 0 en Go, got %d", cfg.SmtpPort)
}
}
-11
View File
@@ -82,14 +82,3 @@ 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
}
-109
View File
@@ -1,109 +0,0 @@
package models
import (
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
type Tarea struct {
gorm.Model
Titulo string `json:"titulo" gorm:"column:titulo;size:200"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Estado string `json:"estado" gorm:"column:estado;default:'por_hacer';index"`
Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"`
AsignadoID *uint `json:"asignado_id" gorm:"column:asignado_id;index"`
Asignado *Users `json:"asignado" gorm:"foreignKey:AsignadoID"`
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
CreadoPor *Users `json:"creado_por" gorm:"foreignKey:CreadoPorID"`
FechaLimite *time.Time `json:"fecha_limite" gorm:"column:fecha_limite"`
Orden int `json:"orden" gorm:"column:orden;default:0"`
}
func (Tarea) TableName() string { return "tarea" }
// RepararEstadosTareaInvalidos corrige tareas que quedaron con un estado que el
// tablero Kanban no reconoce (columnas válidas: por_hacer, en_progreso, revision,
// hecho). El agente de Telegram creó tareas con "pendiente"/"completada"/
// "cancelada" antes de esta corrección: se guardaban bien en la base de datos
// pero no aparecían en ninguna columna del listado. Es idempotente — una vez
// corregidas, no vuelve a tocarlas.
func RepararEstadosTareaInvalidos() (int64, error) {
db := app.Http.Database.DB
var total int64
mapa := map[string]string{
"pendiente": "por_hacer",
"completada": "hecho",
"cancelada": "hecho",
}
for viejo, nuevo := range mapa {
result := db.Model(&Tarea{}).Where("estado = ?", viejo).Update("estado", nuevo)
if result.Error != nil {
return total, result.Error
}
total += result.RowsAffected
}
return total, nil
}
type TareaComentario struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
TareaID uint `json:"tarea_id" gorm:"column:tarea_id;index"`
AutorID uint `json:"autor_id" gorm:"column:autor_id"`
Autor *Users `json:"autor" gorm:"foreignKey:AutorID"`
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
Archivos string `json:"archivos" gorm:"column:archivos;type:text"`
CreatedAt time.Time `json:"created_at"`
}
func (TareaComentario) TableName() string { return "tarea_comentario" }
func GetAllTareas() ([]Tarea, error) {
var items []Tarea
err := app.Http.Database.DB.
Preload("Asignado").
Preload("CreadoPor").
Order("estado ASC, orden ASC, created_at ASC").
Find(&items).Error
return items, err
}
func GetTareaByID(id uint) (*Tarea, error) {
var t Tarea
err := app.Http.Database.DB.
Preload("Asignado").
Preload("CreadoPor").
First(&t, id).Error
return &t, err
}
func CreateTarea(t *Tarea) error {
return app.Http.Database.DB.Create(t).Error
}
func SaveTarea(t *Tarea) error {
return app.Http.Database.DB.Save(t).Error
}
func DeleteTarea(id uint) error {
return app.Http.Database.DB.Delete(&Tarea{}, id).Error
}
func CambiarEstadoTarea(id uint, estado string) error {
return app.Http.Database.DB.Model(&Tarea{}).Where("id = ?", id).Update("estado", estado).Error
}
func GetComentariosByTarea(tareaID uint) ([]TareaComentario, error) {
var items []TareaComentario
err := app.Http.Database.DB.
Preload("Autor").
Where("tarea_id = ?", tareaID).
Order("created_at ASC").
Find(&items).Error
return items, err
}
func CreateComentario(c *TareaComentario) error {
return app.Http.Database.DB.Create(c).Error
}
-75
View File
@@ -1,75 +0,0 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// Tarifa es la tabla de precios/reglas de negocio que la IA usa para calcular
// cotizaciones y contratos: valor por hora según tipo de servicio, licencias M365,
// tipos de VM Azure recurrentes, márgenes estándar, etc.
type Tarifa struct {
gorm.Model
Categoria string `json:"categoria" gorm:"column:categoria;size:50;not null;index"` // hora_servicio | licencia | vm_azure | margen | otro
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
Valor float64 `json:"valor" gorm:"column:valor;not null"`
Moneda string `json:"moneda" gorm:"column:moneda;size:10;default:'COP'"`
Unidad string `json:"unidad" gorm:"column:unidad;size:20"` // hora | mes | unico | porcentaje
Notas string `json:"notas" gorm:"column:notas;type:text"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
}
func (Tarifa) TableName() string { return "tarifas" }
func GetAllTarifas(limit, offset int, search, categoria string) ([]Tarifa, int64, error) {
var items []Tarifa
var total int64
db := app.Http.Database.DB.Model(&Tarifa{})
if search != "" {
db = db.Where("nombre ILIKE ?", "%"+search+"%")
}
if categoria != "" {
db = db.Where("categoria = ?", categoria)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("categoria ASC, nombre ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
// GetTarifasActivas retorna todas las tarifas activas, opcionalmente filtradas por categoría.
// Es lo que usan los endpoints de generación para calcular precios.
func GetTarifasActivas(categoria string) ([]Tarifa, error) {
var items []Tarifa
db := app.Http.Database.DB.Where("activo = ?", true)
if categoria != "" {
db = db.Where("categoria = ?", categoria)
}
if err := db.Order("nombre ASC").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func GetTarifaByID(id uint) (*Tarifa, error) {
var item Tarifa
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
return nil, err
}
return &item, nil
}
func CreateTarifa(t Tarifa) error {
return app.Http.Database.DB.Create(&t).Error
}
func UpdateTarifa(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&Tarifa{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteTarifa(id uint) error {
return app.Http.Database.DB.Delete(&Tarifa{}, id).Error
}
-97
View File
@@ -1,97 +0,0 @@
package models
import (
"fmt"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// TelegramAgentHistory guarda el historial de conversación por chat_id.
// Se usan los últimos N mensajes como contexto en cada llamada al AI.
type TelegramAgentHistory struct {
gorm.Model
ChatID int64 `json:"chat_id" gorm:"column:chat_id;index;not null"`
Role string `json:"role" gorm:"column:role;not null"` // user | assistant | tool
Content string `json:"content" gorm:"column:content;type:text;not null"`
// ToolName y ToolResult se usan para mensajes de tipo "tool"
ToolName string `json:"tool_name" gorm:"column:tool_name"`
ToolResult string `json:"tool_result" gorm:"column:tool_result;type:text"`
}
func (TelegramAgentHistory) TableName() string { return "telegram_agent_history" }
// TelegramAgentChatID guarda qué chat_ids están autorizados a usar el agente.
type TelegramAgentAuth struct {
gorm.Model
ChatID int64 `json:"chat_id" gorm:"column:chat_id;uniqueIndex;not null"`
Nombre string `json:"nombre" gorm:"column:nombre"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
}
func (TelegramAgentAuth) TableName() string { return "telegram_agent_auth" }
// GetAgentHistory retorna los últimos n mensajes del historial para un chat_id.
func GetAgentHistory(chatID int64, n int) ([]TelegramAgentHistory, error) {
var items []TelegramAgentHistory
err := app.Http.Database.DB.
Where("chat_id = ?", chatID).
Order("created_at DESC").
Limit(n).
Find(&items).Error
// Invertir para orden cronológico
for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 {
items[i], items[j] = items[j], items[i]
}
return items, err
}
func SaveAgentMessage(chatID int64, role, content, toolName, toolResult string) error {
msg := &TelegramAgentHistory{
ChatID: chatID,
Role: role,
Content: content,
ToolName: toolName,
ToolResult: toolResult,
}
return app.Http.Database.DB.Create(msg).Error
}
// ClearAgentHistory borra el historial de un chat (comando /reset).
func ClearAgentHistory(chatID int64) error {
return app.Http.Database.DB.Where("chat_id = ?", chatID).Delete(&TelegramAgentHistory{}).Error
}
// IsAgentAuthChat verifica si un chat_id está autorizado.
func IsAgentAuthChat(chatID int64) bool {
var auth TelegramAgentAuth
err := app.Http.Database.DB.Where("chat_id = ? AND activo = ?", chatID, true).First(&auth).Error
return err == nil
}
func GetAllAgentAuth() ([]TelegramAgentAuth, error) {
var items []TelegramAgentAuth
err := app.Http.Database.DB.Order("created_at DESC").Find(&items).Error
return items, err
}
func CreateAgentAuth(chatID int64, nombre string) error {
auth := &TelegramAgentAuth{ChatID: chatID, Nombre: nombre, Activo: true}
return app.Http.Database.DB.Create(auth).Error
}
func DeleteAgentAuth(id uint) error {
return app.Http.Database.DB.Delete(&TelegramAgentAuth{}, id).Error
}
// GetAgentTelegramConfig retorna el TelegramConfig vinculado al agente bot activo.
func GetAgentTelegramConfig() (*TelegramConfig, error) {
var ai AiConfig
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil {
return nil, fmt.Errorf("no hay agente bot configurado")
}
if ai.TelegramConfigID == nil {
return nil, fmt.Errorf("el agente no tiene Telegram configurado")
}
return GetTelegramConfigByID(*ai.TelegramConfigID)
}
-58
View File
@@ -1,58 +0,0 @@
package models
import (
"crypto/rand"
"fmt"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// TelegramStaffToken almacena un código de verificación temporal para que un
// usuario interno (staff) vincule su Telegram y reciba notificaciones personales
// (ej: tareas asignadas), igual que TelegramPortalToken pero para Users.
type TelegramStaffToken struct {
gorm.Model
UserID uint `json:"user_id" gorm:"column:user_id;uniqueIndex"`
Token string `json:"token" gorm:"column:token;uniqueIndex;size:8"`
}
func (TelegramStaffToken) TableName() string { return "telegram_staff_tokens" }
// GenerateTelegramStaffToken genera (o renueva) el código de vinculación para el usuario.
func GenerateTelegramStaffToken(userID uint) (*TelegramStaffToken, error) {
app.Http.Database.DB.Unscoped().Where("user_id = ?", userID).Delete(&TelegramStaffToken{})
b := make([]byte, 3)
if _, err := rand.Read(b); err != nil {
return nil, fmt.Errorf("no se pudo generar token: %w", err)
}
token := fmt.Sprintf("%06X", b)
t := &TelegramStaffToken{UserID: userID, Token: token}
if err := app.Http.Database.DB.Create(t).Error; err != nil {
return nil, err
}
return t, nil
}
// GetTelegramStaffTokenByUser devuelve el token vigente de un usuario interno.
func GetTelegramStaffTokenByUser(userID uint) (*TelegramStaffToken, error) {
var t TelegramStaffToken
err := app.Http.Database.DB.Where("user_id = ?", userID).First(&t).Error
if err != nil {
return nil, err
}
return &t, nil
}
// DeleteTelegramStaffToken elimina el token de un usuario (tras vincular correctamente).
func DeleteTelegramStaffToken(userID uint) {
app.Http.Database.DB.Unscoped().Where("user_id = ?", userID).Delete(&TelegramStaffToken{})
}
// UpdateUserTelegramChatID vincula el chat_id de Telegram al usuario interno.
func UpdateUserTelegramChatID(userID uint, chatID string) error {
return app.Http.Database.DB.Model(&Users{}).Where("id = ?", userID).
Update("telegram_chat_id", chatID).Error
}
-406
View File
@@ -1,406 +0,0 @@
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
}
-122
View File
@@ -1,122 +0,0 @@
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)
}
-153
View File
@@ -1,153 +0,0 @@
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
}
-86
View File
@@ -1,86 +0,0 @@
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
}

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