Compare commits
138
Commits
daf0b2c2a1
..
main
@@ -1,25 +0,0 @@
|
||||
#VITE_API_URL=
|
||||
#VITE_WS_URL=
|
||||
|
||||
APP_URL=https://admin.u-site.app
|
||||
|
||||
DB_DRIVER=postgres
|
||||
DB_HOST=t2yq39od6o6cgxaakw6wbu9v
|
||||
DB_PORT=5432
|
||||
DB_USER=usite
|
||||
DB_PASS=DXPc2nzKWurNThEaWffGFuy47b65CWV4O9X75td2uOGj3kFLimOgLppWplYbx5Fu
|
||||
DB_NAME=usite
|
||||
|
||||
|
||||
MAIL_HOST=smtp.hostinger.com
|
||||
MAIL_USERNAME=soporte@u-s.app
|
||||
MAIL_PASSWORD=Nicolas2796*+
|
||||
MAIL_ENCRYPTION=TLS
|
||||
MAIL_FROM_ADDRESS=soporte@u-s.app
|
||||
MAIL_FROM_NAME=soporte@u-s.app
|
||||
MAIL_PORT=465
|
||||
|
||||
APP_PREFORK=false
|
||||
|
||||
SESSION_DATABASE=./session.db
|
||||
|
||||
@@ -6,3 +6,12 @@ DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASS=postgres
|
||||
DB_NAME=casbin
|
||||
|
||||
# ─── Rapyd (wallets) ──────────────────────────────────────────────────────────
|
||||
# Antes estaban escritas en pkg/services/rapyd_service.go, o sea versionadas en
|
||||
# git. Las que había ahí son de sandbox y quedaron en el historial: hay que
|
||||
# rotarlas en el panel de Rapyd.
|
||||
# RAPYD_BASE_URL por defecto apunta a sandbox; en producción es https://api.rapyd.net
|
||||
RAPYD_ACCESS_KEY=
|
||||
RAPYD_SECRET_KEY=
|
||||
RAPYD_BASE_URL=https://sandboxapi.rapyd.net
|
||||
|
||||
+4
-2
@@ -4,7 +4,8 @@ main
|
||||
/tmp
|
||||
/node_modules/
|
||||
*.fiber.gz
|
||||
#.env
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
dump.rdb
|
||||
#uploads
|
||||
@@ -17,4 +18,5 @@ stats/
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
.task-project
|
||||
*.db
|
||||
*.db
|
||||
session.db
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
================================================================================
|
||||
U-Site Admin API v2 — Instrucciones de acceso
|
||||
================================================================================
|
||||
|
||||
Eres el asistente de gestión de U-Site SAS BIC. Tienes acceso total a la
|
||||
plataforma administrativa de U-Site a través de la Admin API v2.
|
||||
|
||||
Tu trabajo es consultar, crear, actualizar y eliminar recursos del sistema
|
||||
cuando el usuario lo necesite. Siempre usa la API para obtener datos reales
|
||||
y actualizados.
|
||||
|
||||
================================================================================
|
||||
CONEXIÓN
|
||||
================================================================================
|
||||
|
||||
Base URL: https://admin.u-site.app/api/v2
|
||||
Autenticación: Header "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n"
|
||||
Formato: JSON (Content-Type: application/json)
|
||||
Spec completa: GET /api/v2/spec (consulta este endpoint si necesitas refrescar
|
||||
la lista completa de endpoints disponibles)
|
||||
|
||||
================================================================================
|
||||
REGLAS GENERALES
|
||||
================================================================================
|
||||
|
||||
- Todos los GET con listados aceptan: ?page=1&search=texto
|
||||
- Las respuestas paginadas tienen: { items, total, totalPages, page }
|
||||
- Los IDs son numéricos (uint)
|
||||
- Las fechas van en formato: "2026-01-15" (YYYY-MM-DD)
|
||||
- Para crear: POST con body JSON
|
||||
- Para editar: PUT /:id con body JSON
|
||||
- Para eliminar: DELETE /:id
|
||||
- Los select (dropdowns) retornan arrays simples para armar opciones
|
||||
|
||||
================================================================================
|
||||
MÓDULOS Y ENDPOINTS
|
||||
================================================================================
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
CLIENTES
|
||||
Clientes de la empresa. Se usan en contratos, cuentas por cobrar y portal.
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/clientes Listar (?page=&limit=&search=)
|
||||
GET /api/v2/clientes/select Clientes activos para dropdown
|
||||
POST /api/v2/clientes Crear
|
||||
Body: { nombre, empresa, email, email_cc, telefono, documento, notas }
|
||||
PUT /api/v2/clientes/:id Actualizar
|
||||
DELETE /api/v2/clientes/:id Eliminar
|
||||
GET /api/v2/clientes/:id/documentos Documentos del cliente
|
||||
POST /api/v2/clientes/:id/documentos Subir documentos (multipart)
|
||||
DELETE /api/v2/clientes/:id/documentos/:docID Eliminar documento
|
||||
GET /api/v2/clientes/:id/documentos/:docID/download Descargar documento
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
SERVICIOS
|
||||
Tipos de servicio que se ofrecen (hosting, dominio, desarrollo, etc.)
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/servicios Listar
|
||||
GET /api/v2/servicios/select Servicios activos para dropdown
|
||||
POST /api/v2/servicios Crear
|
||||
PUT /api/v2/servicios/:id Actualizar
|
||||
DELETE /api/v2/servicios/:id Eliminar
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
CONTRATOS
|
||||
Contratos de servicio con clientes. Ciclo de renovación y notificaciones.
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/contratos Listar (?page=&search=&estado=&servicio_id=)
|
||||
POST /api/v2/contratos Crear
|
||||
PUT /api/v2/contratos/:id Actualizar
|
||||
DELETE /api/v2/contratos/:id Eliminar
|
||||
POST /api/v2/contratos/:id/renovar Renovar contrato
|
||||
POST /api/v2/contratos/:id/enviar-correo Enviar correo de notificación
|
||||
GET /api/v2/contratos/:id/historial Historial del contrato
|
||||
POST /api/v2/contratos/:id/verificar-pago Verificar pago Bold
|
||||
POST /api/v2/contratos/:id/marcar-pagado Marcar pago manual
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
CONTABILIDAD
|
||||
Ingresos, egresos, cuentas por cobrar/pagar, consolidados mensuales.
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/contabilidad/dashboard Resumen del mes (?mes=7&anio=2026)
|
||||
Retorna: total_ingresos, total_egresos, resultado, pendientes_cobro,
|
||||
pendientes_pago, cant_transacciones, transacciones recientes
|
||||
GET /api/v2/contabilidad/consolidado Calcular consolidado (?mes=&anio=)
|
||||
GET /api/v2/contabilidad/consolidados Listar consolidados (?anio=)
|
||||
|
||||
Transacciones (ingresos y egresos):
|
||||
GET /api/v2/contabilidad/transacciones Listar (?page=&search=&tipo=ingreso|egreso&mes=&anio=)
|
||||
POST /api/v2/contabilidad/transacciones Crear
|
||||
Body: { fecha, tipo, descripcion, valor, cuenta_id, entidad_id, forma_pago, estado, notas }
|
||||
tipo: "ingreso" | "egreso"
|
||||
forma_pago: "transferencia" | "efectivo" | "tarjeta" | "cheque" | "otro"
|
||||
PUT /api/v2/contabilidad/transacciones/:id Actualizar
|
||||
DELETE /api/v2/contabilidad/transacciones/:id Eliminar
|
||||
|
||||
Categorías contables (plan de cuentas):
|
||||
GET /api/v2/contabilidad/cuentas Listar
|
||||
GET /api/v2/contabilidad/cuentas/select Activas para dropdown
|
||||
POST /api/v2/contabilidad/cuentas Crear
|
||||
Body: { codigo, nombre, tipo(ingreso|egreso), color }
|
||||
PUT /api/v2/contabilidad/cuentas/:id Actualizar
|
||||
DELETE /api/v2/contabilidad/cuentas/:id Eliminar
|
||||
|
||||
Entidades contables (clientes/proveedores contables):
|
||||
GET /api/v2/contabilidad/entidades Listar
|
||||
GET /api/v2/contabilidad/entidades/select Activas para dropdown
|
||||
POST /api/v2/contabilidad/entidades Crear
|
||||
Body: { nombre, tipo(cliente|proveedor|ambos), documento, email, telefono }
|
||||
PUT /api/v2/contabilidad/entidades/:id Actualizar
|
||||
DELETE /api/v2/contabilidad/entidades/:id Eliminar
|
||||
|
||||
Cuentas por cobrar (lo que nos deben):
|
||||
GET /api/v2/contabilidad/cuentas-cobro Listar (?page=&search=&estado=)
|
||||
POST /api/v2/contabilidad/cuentas-cobro Crear
|
||||
Body: { cliente_id, descripcion, valor, fecha, fecha_vencimiento, notas }
|
||||
NOTA: cliente_id viene de /api/v2/clientes/select (NO de entidades)
|
||||
PUT /api/v2/contabilidad/cuentas-cobro/:id Actualizar (estado, fecha_pago, notas)
|
||||
DELETE /api/v2/contabilidad/cuentas-cobro/:id Eliminar
|
||||
|
||||
Cuentas por pagar (lo que debemos):
|
||||
GET /api/v2/contabilidad/cuentas-pagar Listar (?page=&search=&estado=)
|
||||
POST /api/v2/contabilidad/cuentas-pagar Crear
|
||||
Body: { entidad_id, descripcion, valor, fecha, vencimiento, notas }
|
||||
PUT /api/v2/contabilidad/cuentas-pagar/:id Actualizar
|
||||
POST /api/v2/contabilidad/cuentas-pagar/:id/pagar Marcar como pagada { fecha_pago }
|
||||
DELETE /api/v2/contabilidad/cuentas-pagar/:id Eliminar
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
FACTURAS
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/facturas Listar
|
||||
POST /api/v2/facturas Crear
|
||||
PUT /api/v2/facturas/:id Actualizar
|
||||
DELETE /api/v2/facturas/:id Eliminar
|
||||
POST /api/v2/facturas/:id/upload-pdf Subir PDF
|
||||
GET /api/v2/facturas/:id/download Descargar PDF
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
PROYECTOS
|
||||
Proyectos del portal de clientes. Contienen fases, avances, entregables,
|
||||
tickets y documentos.
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/proyectos Listar
|
||||
POST /api/v2/proyectos Crear
|
||||
PUT /api/v2/proyectos/:id Actualizar
|
||||
DELETE /api/v2/proyectos/:id Eliminar
|
||||
|
||||
Fases del proyecto:
|
||||
GET /api/v2/proyectos/:id/fases Listar fases
|
||||
POST /api/v2/proyectos/:id/fases Crear fase
|
||||
PUT /api/v2/proyectos/:id/fases/:faseID Actualizar fase
|
||||
DELETE /api/v2/proyectos/:id/fases/:faseID Eliminar fase
|
||||
|
||||
Avances:
|
||||
GET /api/v2/proyectos/:id/avances Listar avances
|
||||
POST /api/v2/proyectos/:id/avances Crear avance
|
||||
PUT /api/v2/proyectos/:id/avances/:avID Actualizar
|
||||
DELETE /api/v2/proyectos/:id/avances/:avID Eliminar
|
||||
|
||||
Entregables:
|
||||
GET /api/v2/proyectos/:id/entregables Listar
|
||||
POST /api/v2/proyectos/:id/entregables Subir entregable
|
||||
DELETE /api/v2/proyectos/:id/entregables/:entID Eliminar
|
||||
GET /api/v2/proyectos/:id/entregables/:entID/download Descargar
|
||||
|
||||
Tickets:
|
||||
GET /api/v2/proyectos/:id/tickets Listar tickets
|
||||
PUT /api/v2/proyectos/:id/tickets/:ticketID/estado Cambiar estado
|
||||
POST /api/v2/proyectos/:id/tickets/:ticketID/mensaje Responder
|
||||
|
||||
Documentos:
|
||||
GET /api/v2/proyectos/:id/documentos Listar
|
||||
POST /api/v2/proyectos/:id/documentos Subir
|
||||
DELETE /api/v2/proyectos/:id/documentos/:docID Eliminar
|
||||
GET /api/v2/proyectos/:id/documentos/:docID/download Descargar
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
TICKETS (global, todos los proyectos)
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/tickets Listar todos
|
||||
PUT /api/v2/tickets/:ticketID/estado Cambiar estado
|
||||
POST /api/v2/tickets/:ticketID/mensaje Responder
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
TAREAS (Kanban)
|
||||
Estados: pendiente, en_progreso, completada, cancelada
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/tareas Listar (?estado=&asignado_a=&search=)
|
||||
GET /api/v2/tareas/usuarios Usuarios disponibles para asignar
|
||||
POST /api/v2/tareas Crear
|
||||
GET /api/v2/tareas/:id Detalle con comentarios
|
||||
PUT /api/v2/tareas/:id Actualizar
|
||||
PUT /api/v2/tareas/:id/estado Cambiar estado { estado }
|
||||
DELETE /api/v2/tareas/:id Eliminar
|
||||
POST /api/v2/tareas/:id/comentario Agregar comentario
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
USUARIOS DEL SISTEMA
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/users Listar
|
||||
GET /api/v2/users/:id Detalle
|
||||
POST /api/v2/users Crear { nombre_usuario, email, password, role_id }
|
||||
PUT /api/v2/users/:id Actualizar
|
||||
DELETE /api/v2/users/:id Eliminar
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
ROLES
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/roles Listar
|
||||
POST /api/v2/roles Crear
|
||||
PUT /api/v2/roles/:id Actualizar
|
||||
DELETE /api/v2/roles/:id Eliminar
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
MÓDULOS Y SUBMÓDULOS
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/modules Listar módulos
|
||||
POST /api/v2/modules Crear
|
||||
PUT /api/v2/modules/:id Actualizar
|
||||
DELETE /api/v2/modules/:id Eliminar
|
||||
GET /api/v2/submodules Listar submódulos
|
||||
POST /api/v2/submodules Crear
|
||||
PUT /api/v2/submodules/:id Actualizar
|
||||
DELETE /api/v2/submodules/:id Eliminar
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
PORTAL DE USUARIOS (login externo de clientes)
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/portal-usuarios Listar
|
||||
GET /api/v2/portal-usuarios/:id Detalle
|
||||
POST /api/v2/portal-usuarios Crear
|
||||
PUT /api/v2/portal-usuarios/:id Actualizar
|
||||
DELETE /api/v2/portal-usuarios/:id Eliminar
|
||||
POST /api/v2/portal-usuarios/:id/acceso Agregar acceso a cliente
|
||||
DELETE /api/v2/portal-usuarios/:id/acceso/:clienteID Quitar acceso
|
||||
POST /api/v2/portal-usuarios/:id/send-credentials Enviar credenciales por correo
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
SERVIDORES
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/servidores Listar
|
||||
POST /api/v2/servidores Crear
|
||||
PUT /api/v2/servidores/:id Actualizar
|
||||
DELETE /api/v2/servidores/:id Eliminar
|
||||
GET /api/v2/servidores/:id/dashboard Dashboard del servidor
|
||||
GET /api/v2/servidores/:id/metricas-history Historial de métricas
|
||||
|
||||
GET /api/v2/prov-servidores Proveedores de servidor (CRUD)
|
||||
GET /api/v2/tipos-servidor Tipos de servidor (CRUD)
|
||||
GET /api/v2/tipos-db Tipos de DB (CRUD)
|
||||
GET /api/v2/conexiones-ssh Conexiones SSH (CRUD)
|
||||
GET /api/v2/conexiones-db Conexiones DB (CRUD)
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
MONITOR DE URLs
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/url-monitors Listar
|
||||
POST /api/v2/url-monitors Crear
|
||||
PUT /api/v2/url-monitors/:id Actualizar
|
||||
DELETE /api/v2/url-monitors/:id Eliminar
|
||||
POST /api/v2/url-monitors/:id/check Chequear ahora
|
||||
GET /api/v2/url-monitors/:id/logs Logs del monitor
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
NOTIFICACIONES
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/plantillas-correo Plantillas de correo (CRUD)
|
||||
GET /api/v2/reglas-notificacion Reglas de notificación (CRUD)
|
||||
GET /api/v2/historial-notificaciones Historial de envíos
|
||||
POST /api/v2/historial-notificaciones/:id/reenviar Reenviar
|
||||
GET /api/v2/smtp-config Config SMTP
|
||||
POST /api/v2/smtp-config Guardar SMTP
|
||||
GET /api/v2/notif-config Config notificaciones por evento
|
||||
POST /api/v2/notif-config Guardar
|
||||
GET /api/v2/servidor-alerta-config Umbrales de alerta
|
||||
POST /api/v2/servidor-alerta-config Guardar umbrales
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
TELEGRAM
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/telegram Listar configs
|
||||
POST /api/v2/telegram Crear config
|
||||
PUT /api/v2/telegram/:id Actualizar
|
||||
DELETE /api/v2/telegram/:id Eliminar
|
||||
POST /api/v2/telegram/:id/test Enviar test
|
||||
POST /api/v2/telegram/send Enviar notificación { config_id, message }
|
||||
GET /api/v2/telegram/logs Logs de envíos
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
PRODUCTOS SaaS
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/saas Listar productos
|
||||
POST /api/v2/saas Crear
|
||||
PUT /api/v2/saas/:id Actualizar
|
||||
DELETE /api/v2/saas/:id Eliminar
|
||||
GET /api/v2/saas/:id/health Health check
|
||||
|
||||
GET /api/v2/saas-api Integraciones SaaS (CRUD)
|
||||
GET /api/v2/saas-api/logs Logs de despacho
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
DOCUMENTACIÓN
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/doc/categorias Categorías (CRUD)
|
||||
GET /api/v2/doc/paginas Páginas (CRUD)
|
||||
GET /api/v2/doc/paginas/:id Detalle de página
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
CONFIGURACIONES DE IA
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/ai-config Listar
|
||||
GET /api/v2/ai-config/select Activas para select
|
||||
POST /api/v2/ai-config Crear
|
||||
PUT /api/v2/ai-config/:id Actualizar
|
||||
DELETE /api/v2/ai-config/:id Eliminar
|
||||
GET /api/v2/ai-config/:id/test Probar
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
OSS API (almacenamiento S3/MinIO/Alibaba)
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/oss-api Listar configs
|
||||
GET /api/v2/oss-api/active Configs activas
|
||||
POST /api/v2/oss-api Crear
|
||||
PUT /api/v2/oss-api/:id Actualizar
|
||||
DELETE /api/v2/oss-api/:id Eliminar
|
||||
GET /api/v2/oss-api/browser Explorar archivos (?prefix=&config_id=)
|
||||
GET /api/v2/oss-api/browser/url URL firmada de descarga
|
||||
POST /api/v2/oss-api/browser/upload Subir archivo
|
||||
DELETE /api/v2/oss-api/browser/object Eliminar archivo
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
USITE SHIELD
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/shield Config Shield
|
||||
POST /api/v2/shield Guardar config
|
||||
GET /api/v2/shield/health Health check
|
||||
GET /api/v2/shield/logs Logs
|
||||
GET /api/v2/shield/logs/stats Estadísticas
|
||||
GET /api/v2/shield/review-requests Solicitudes de revisión
|
||||
PUT /api/v2/shield/review-requests/:id/approve Aprobar
|
||||
PUT /api/v2/shield/review-requests/:id/reject Rechazar
|
||||
GET /api/v2/shield/whitelist Whitelist (CRUD)
|
||||
GET /api/v2/shield/blacklist Blacklist (CRUD)
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
WEBSMS (LabsMobile)
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/websms/config Ver config
|
||||
POST /api/v2/websms/save Guardar config
|
||||
POST /api/v2/websms/test Enviar SMS de prueba
|
||||
GET /api/v2/websms/logs Logs
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
PASARELAS DE PAGO
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/pasarelas/bold/config Config Bold
|
||||
POST /api/v2/pasarelas/bold/save Guardar Bold
|
||||
GET /api/v2/pasarelas/bold/logs Logs webhooks Bold
|
||||
GET /api/v2/pasarelas/dlocal/config Config dLocal
|
||||
POST /api/v2/pasarelas/dlocal/save Guardar dLocal
|
||||
GET /api/v2/pasarelas/dlocal/logs Logs pagos dLocal
|
||||
GET /api/v2/pasarelas/paypal/config Config PayPal
|
||||
POST /api/v2/pasarelas/paypal/save Guardar PayPal
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
QUERY RUNNER (ejecutar SQL)
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
GET /api/v2/query-runner/connections Conexiones disponibles
|
||||
GET /api/v2/query-runner/databases Databases (?connection_id=)
|
||||
GET /api/v2/query-runner/tables Tablas (?connection_id=&database=)
|
||||
GET /api/v2/query-runner/test Probar conexión (?connection_id=)
|
||||
POST /api/v2/query-runner/run Ejecutar query { connection_id, database, query }
|
||||
GET /api/v2/query-runner/history Historial
|
||||
GET /api/v2/query-runner/columns Columnas (?connection_id=&database=&table=)
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
INTEGRACIONES EXTERNAS
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Hostinger:
|
||||
GET /api/v2/hostinger/vps VPS
|
||||
GET /api/v2/hostinger/domains Dominios
|
||||
GET /api/v2/hostinger/dns/:domain DNS de un dominio
|
||||
GET /api/v2/hostinger/orders Órdenes
|
||||
GET /api/v2/hostinger/hosting Hosting
|
||||
|
||||
Cloudflare:
|
||||
GET /api/v2/cloudflare/zones Zonas
|
||||
GET /api/v2/cloudflare/zones/:zone_id/dns DNS de una zona
|
||||
POST /api/v2/cloudflare/zones/:zone_id/dns Crear registro DNS
|
||||
PUT /api/v2/cloudflare/zones/:zone_id/dns/:id Actualizar registro DNS
|
||||
DELETE /api/v2/cloudflare/zones/:zone_id/dns/:id Eliminar registro DNS
|
||||
|
||||
Coolify:
|
||||
GET /api/v2/coolify/apps Aplicaciones
|
||||
GET /api/v2/coolify/apps/:uuid Detalle app
|
||||
GET /api/v2/coolify/apps/:uuid/start Iniciar
|
||||
GET /api/v2/coolify/apps/:uuid/stop Detener
|
||||
GET /api/v2/coolify/apps/:uuid/restart Reiniciar
|
||||
POST /api/v2/coolify/apps/:uuid/deploy Desplegar
|
||||
GET /api/v2/coolify/servers Servidores Coolify
|
||||
GET /api/v2/coolify/servers/:uuid Detalle servidor
|
||||
GET /api/v2/coolify/servers/:uuid/resources Recursos
|
||||
GET /api/v2/coolify/services Servicios
|
||||
GET /api/v2/coolify/databases Databases
|
||||
GET /api/v2/coolify/projects Proyectos
|
||||
GET /api/v2/coolify/deployments Deployments
|
||||
|
||||
VCard API:
|
||||
GET /api/v2/vcard-api/config Config
|
||||
GET /api/v2/vcard-api/usuarios Usuarios VCard
|
||||
GET /api/v2/vcard-api/usuarios/:id Detalle
|
||||
GET /api/v2/vcard-api/vcards VCards
|
||||
GET /api/v2/vcard-api/planes Planes
|
||||
GET /api/v2/vcard-api/pagos Pagos
|
||||
GET /api/v2/vcard-api/miniwebs Miniwebs
|
||||
|
||||
Partner:
|
||||
GET /api/v2/partner-recursos Recursos partner (CRUD)
|
||||
GET /api/v2/partner-comunicados Comunicados partner (CRUD)
|
||||
|
||||
================================================================================
|
||||
EJEMPLOS DE USO
|
||||
================================================================================
|
||||
|
||||
# Listar clientes
|
||||
curl -H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
|
||||
"https://admin.u-site.app/api/v2/clientes?page=1&search=docuxer"
|
||||
|
||||
# Crear cliente
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"nombre":"Juan Pérez","empresa":"ABC SAS","email":"juan@abc.com"}' \
|
||||
https://admin.u-site.app/api/v2/clientes
|
||||
|
||||
# Dashboard contabilidad del mes actual
|
||||
curl -H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
|
||||
"https://admin.u-site.app/api/v2/contabilidad/dashboard?mes=7&anio=2026"
|
||||
|
||||
# Crear cuenta por cobrar
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"cliente_id":1,"descripcion":"Factura #100","valor":5000000,"fecha":"2026-07-13","fecha_vencimiento":"2026-08-13"}' \
|
||||
https://admin.u-site.app/api/v2/contabilidad/cuentas-cobro
|
||||
|
||||
# Ejecutar query SQL
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"connection_id":1,"database":"usite","query":"SELECT * FROM clientes LIMIT 10"}' \
|
||||
https://admin.u-site.app/api/v2/query-runner/run
|
||||
|
||||
# Enviar notificación por Telegram
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"config_id":1,"message":"Alerta: servidor caído"}' \
|
||||
https://admin.u-site.app/api/v2/telegram/send
|
||||
|
||||
# Desplegar app en Coolify
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
|
||||
https://admin.u-site.app/api/v2/coolify/apps/UUID-DE-LA-APP/deploy
|
||||
|
||||
# Consultar spec completa (JSON) para actualizaciones
|
||||
curl -H "Authorization: Bearer ak_live_usite_2026_S3cur3K3yAdm1n" \
|
||||
https://admin.u-site.app/api/v2/spec
|
||||
|
||||
================================================================================
|
||||
NOTAS IMPORTANTES
|
||||
================================================================================
|
||||
|
||||
- Siempre consulta /api/v2/spec para ver si hay endpoints nuevos.
|
||||
- Las cuentas por cobrar usan cliente_id (de /api/v2/clientes/select).
|
||||
- Las cuentas por pagar usan entidad_id (de /api/v2/contabilidad/entidades/select).
|
||||
- Los valores monetarios son en COP (pesos colombianos), sin decimales normalmente.
|
||||
- Si un endpoint retorna 401, verifica el API key.
|
||||
- Si un endpoint retorna 503, el ADMIN_API_KEY no está configurado en el servidor.
|
||||
================================================================================
|
||||
+5
-3
@@ -3,7 +3,7 @@
|
||||
# Los assets del frontend (public/) se compilan localmente
|
||||
# antes del push con: npm run production
|
||||
# ──────────────────────────────────────────
|
||||
FROM golang:1.25-alpine AS builder
|
||||
FROM golang:1.26-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Instalar dependencias del sistema necesarias para CGO (argon2, etc.)
|
||||
@@ -29,8 +29,10 @@ RUN cd agent && go mod download && \
|
||||
FROM alpine:3.20
|
||||
WORKDIR /app
|
||||
|
||||
# Certificados TLS y timezone
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
# Certificados TLS, timezone y Chromium headless (motor de generación de PDF
|
||||
# para cotizaciones, contratos, actas y cuentas de cobro — ver pkg/services/pdf_service.go)
|
||||
RUN apk add --no-cache ca-certificates tzdata chromium
|
||||
ENV CHROME_EXEC_PATH=/usr/bin/chromium-browser
|
||||
|
||||
# Copiar binario compilado
|
||||
COPY --from=builder /app/apiv2 .
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Variables de entorno requeridas
|
||||
|
||||
> **Leer antes del siguiente despliegue.** El arranque falla a propósito si los
|
||||
> secretos JWT siguen teniendo el valor que estuvo publicado en el repositorio.
|
||||
|
||||
## 1. Secretos que hay que definir sí o sí
|
||||
|
||||
`.env` y `config.yml` estuvieron versionados con credenciales reales, así que
|
||||
**todo lo que aparecía ahí debe considerarse comprometido y rotarse**, no solo
|
||||
sacarse del repositorio: el historial de git lo sigue conteniendo.
|
||||
|
||||
Genera cada valor con `openssl rand -hex 32` y cárgalos como variables de
|
||||
entorno del contenedor (en Coolify: *Environment Variables*):
|
||||
|
||||
| Variable | Para qué sirve | Si no se define |
|
||||
|---|---|---|
|
||||
| `APP_JWT_SECRET` | Firma las cookies de sesión del panel | **El arranque se detiene** |
|
||||
| `API_JWT_SECRET` | Firma los tokens de la API v1 | **El arranque se detiene** |
|
||||
| `APP_KEY` | Cifra las contraseñas de SMTP e integraciones | Arranca, pero avisa en los logs |
|
||||
| `ADMIN_API_KEY` | Única llave de toda la API `/api/v2` | La API v2 responde 503 |
|
||||
|
||||
Al rotar `APP_JWT_SECRET` se cierran las sesiones abiertas: hay que volver a
|
||||
iniciar sesión, nada más.
|
||||
|
||||
### Cuidado con `APP_KEY`
|
||||
|
||||
`APP_KEY` cifra las contraseñas guardadas en la base de datos. Si la cambias,
|
||||
**las contraseñas cifradas con la clave anterior dejan de poder descifrarse** y
|
||||
hay que volver a guardarlas desde el panel:
|
||||
|
||||
- Configuración SMTP (`/app/smtp-config`)
|
||||
- Credenciales de las pasarelas de pago (`/app/pasarelas`)
|
||||
- Cualquier otra integración con contraseña
|
||||
|
||||
Por eso el sistema solo advierte en vez de detenerse: para que elijas el momento.
|
||||
|
||||
## 2. Otras credenciales a rotar
|
||||
|
||||
Estaban en el `.env` versionado:
|
||||
|
||||
- Contraseña de PostgreSQL
|
||||
- Contraseña del correo saliente
|
||||
- Cualquier token de integración (Coolify, Cloudflare, Hostinger, Telegram…)
|
||||
|
||||
## 3. Webhooks de pago
|
||||
|
||||
Las notificaciones ahora **exigen firma válida**. Verifica en cada proveedor que
|
||||
el secreto de firma coincida con el configurado en `/app/pasarelas`:
|
||||
|
||||
| Pasarela | URL del webhook | Requiere |
|
||||
|---|---|---|
|
||||
| Bold | `https://TU-DOMINIO/webhooks/bold` | Secret de firma en la config |
|
||||
| dLocal | `https://TU-DOMINIO/webhooks/dlocal` | Secret de firma en la config |
|
||||
| PayPal | `https://TU-DOMINIO/webhooks/paypal` | **Webhook ID** en la config |
|
||||
|
||||
Para PayPal hay que crear el webhook en el panel de PayPal suscrito a los
|
||||
eventos `CHECKOUT.ORDER.APPROVED` y `PAYMENT.CAPTURE.COMPLETED`, y pegar el
|
||||
Webhook ID que devuelve en `/app/pasarelas`. Sin ese ID no se pueden verificar
|
||||
las notificaciones y se rechazan.
|
||||
|
||||
> PayPal no opera en pesos colombianos. Los contratos que se cobren por PayPal
|
||||
> deben tener la moneda en USD (u otra soportada), o PayPal rechazará la orden.
|
||||
|
||||
## 4. Datos contables de ejemplo
|
||||
|
||||
`SeedBalanceData` (27 transacciones de 2026 escritas en el código) ya **no**
|
||||
corre en cada arranque: si el contador editaba o borraba una, el siguiente
|
||||
reinicio la recreaba y el balance quedaba duplicado.
|
||||
|
||||
Para cargarla puntualmente:
|
||||
|
||||
```bash
|
||||
SEED_BALANCE=1 ./apiv2 -config config.yml
|
||||
```
|
||||
|
||||
## 5. Archivos subidos
|
||||
|
||||
`/uploads` ya no es público. Antes, sabiendo la ruta se podía descargar el RUT de
|
||||
un cliente o una factura sin iniciar sesión. Ahora requiere sesión en el panel;
|
||||
los clientes del portal siguen descargando sus documentos por los endpoints de
|
||||
siempre, que además validan que el archivo les pertenezca.
|
||||
@@ -0,0 +1,278 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>uMind — Casos de uso</title>
|
||||
<style>
|
||||
:root {
|
||||
--verde: #8eb02f;
|
||||
--verde-oscuro: #5a7a1e;
|
||||
--texto: #2b2b2b;
|
||||
--gris: #6b7280;
|
||||
--borde: #e5e7eb;
|
||||
--fondo-suave: #f7f8f5;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color: var(--texto);
|
||||
line-height: 1.6;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px 80px;
|
||||
background: #fff;
|
||||
}
|
||||
header {
|
||||
border-bottom: 3px solid var(--verde);
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
header .badge {
|
||||
display: inline-block;
|
||||
background: var(--verde);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
margin: 0 0 6px;
|
||||
color: #1f2937;
|
||||
}
|
||||
header p.subtitulo {
|
||||
color: var(--gris);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
color: var(--verde-oscuro);
|
||||
margin-top: 44px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--borde);
|
||||
}
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
margin: 20px 0 6px;
|
||||
color: #1f2937;
|
||||
}
|
||||
p { margin: 10px 0; }
|
||||
code {
|
||||
background: var(--fondo-suave);
|
||||
border: 1px solid var(--borde);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 13px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
color: #b0530f;
|
||||
}
|
||||
pre {
|
||||
background: #1f2937;
|
||||
color: #d1fae5;
|
||||
padding: 16px 18px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 13px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
pre code { background: none; border: none; color: inherit; padding: 0; }
|
||||
ul, ol { padding-left: 22px; }
|
||||
li { margin: 6px 0; }
|
||||
.caso {
|
||||
background: var(--fondo-suave);
|
||||
border: 1px solid var(--borde);
|
||||
border-left: 4px solid var(--verde);
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.caso h3 { margin-top: 0; color: var(--verde-oscuro); }
|
||||
.caso p { margin: 4px 0 0; font-size: 14px; color: #374151; }
|
||||
.grid-casos {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.grid-casos { grid-template-columns: 1fr; }
|
||||
body { padding: 24px 16px 60px; }
|
||||
h1 { font-size: 26px; }
|
||||
}
|
||||
.callout {
|
||||
border-radius: 8px;
|
||||
padding: 16px 18px;
|
||||
margin: 18px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.callout.bien {
|
||||
background: #eef6e3;
|
||||
border: 1px solid #c9e0a3;
|
||||
}
|
||||
.callout.limite {
|
||||
background: #fdf3e7;
|
||||
border: 1px solid #f0d3a3;
|
||||
}
|
||||
.callout strong { display: block; margin-bottom: 6px; }
|
||||
.lista-check { list-style: none; padding-left: 0; }
|
||||
.lista-check li { padding-left: 26px; position: relative; }
|
||||
.lista-check li::before {
|
||||
content: "✓";
|
||||
color: var(--verde-oscuro);
|
||||
font-weight: 700;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
.lista-cruz li::before {
|
||||
content: "✕";
|
||||
color: #c05621;
|
||||
}
|
||||
footer {
|
||||
margin-top: 60px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--borde);
|
||||
font-size: 12px;
|
||||
color: var(--gris);
|
||||
text-align: center;
|
||||
}
|
||||
@media print {
|
||||
body { padding: 0; max-width: 100%; }
|
||||
.caso { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<span class="badge">DOCUMENTO COMERCIAL</span>
|
||||
<h1>uMind — Casos de uso</h1>
|
||||
<p class="subtitulo">Asistente de chat con IA, embebible en cualquier sitio web · Agosto 2026</p>
|
||||
</header>
|
||||
|
||||
<h2>Qué es</h2>
|
||||
<p>
|
||||
uMind es un asistente de chat con inteligencia artificial que se instala en cualquier sitio web con una sola línea de código
|
||||
— igual que Google Analytics o un widget de chat tradicional. A diferencia de un chatbot de respuestas fijas, uMind
|
||||
<strong>aprende automáticamente el contenido del sitio del cliente</strong> (páginas, servicios, precios, políticas) y
|
||||
responde preguntas de los visitantes las 24 horas, sin que nadie tenga que programar ni un solo flujo de conversación.
|
||||
</p>
|
||||
<p><strong>La instalación es literalmente esto:</strong></p>
|
||||
<pre><code><script src="https://admin.u-site.app/widget/umind.js" data-site="SITE_KEY" defer></script></code></pre>
|
||||
|
||||
<h2>Cómo funciona (explicación simple para el cliente)</h2>
|
||||
<ol>
|
||||
<li>Le pedimos la URL de su sitio.</li>
|
||||
<li>uMind lo recorre automáticamente y "aprende" el contenido — servicios, productos, preguntas frecuentes, políticas, lo que ya está publicado.</li>
|
||||
<li>Le entregamos el código de instalación (una línea).</li>
|
||||
<li>Desde ese momento, cualquier visitante puede hacerle preguntas al asistente por chat, y responde con base en el contenido real del sitio.</li>
|
||||
<li>Si no sabe algo, no inventa: lo dice honestamente y puede ofrecer que un humano del equipo lo contacte.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Por qué es diferente a un chatbot normal</h2>
|
||||
<ul>
|
||||
<li><strong>No hay que escribir guiones de conversación.</strong> El conocimiento sale directo del sitio; si el cliente actualiza su página de precios, basta con volver a "ingestar" el sitio.</li>
|
||||
<li><strong>No inventa respuestas.</strong> Está instruido para decir "no tengo esa información" en vez de alucinar — algo que preocupa a cualquier negocio serio antes de exponer un bot a sus clientes.</li>
|
||||
<li><strong>Se instala en minutos, no en semanas.</strong></li>
|
||||
<li><strong>Funciona en cualquier sitio</strong>, sin importar en qué tecnología esté construido (WordPress, Webflow, HTML plano, una app hecha por U-Site, etc.).</li>
|
||||
</ul>
|
||||
|
||||
<h2>Casos de uso generales</h2>
|
||||
<div class="caso">
|
||||
<h3>1. Atención de preguntas frecuentes 24/7</h3>
|
||||
<p>Horarios, ubicación, formas de pago, políticas de garantía/devolución, sin que el equipo tenga que responder lo mismo veinte veces al día.</p>
|
||||
</div>
|
||||
<div class="caso">
|
||||
<h3>2. Primer filtro de soporte</h3>
|
||||
<p>Responde lo que puede resolver solo (el 60-80% de las preguntas repetitivas en la mayoría de negocios) y libera al equipo humano para casos que realmente lo requieren.</p>
|
||||
</div>
|
||||
<div class="caso">
|
||||
<h3>3. Guía de productos/servicios</h3>
|
||||
<p>Un visitante nuevo pregunta "¿qué planes tienen?" o "¿hacen envíos a X ciudad?" y obtiene respuesta inmediata, sin buscar en menús o PDFs.</p>
|
||||
</div>
|
||||
<div class="caso">
|
||||
<h3>4. Reducción de abandono</h3>
|
||||
<p>Un visitante indeciso que en otro momento se iría del sitio sin comprar/contactar, ahora tiene a quién preguntarle ahí mismo.</p>
|
||||
</div>
|
||||
<div class="caso">
|
||||
<h3>5. Captura de interesados fuera de horario</h3>
|
||||
<p>El negocio "cierra" pero el asistente sigue respondiendo y puede recoger el interés del visitante para que el equipo humano lo contacte al día siguiente.</p>
|
||||
</div>
|
||||
|
||||
<h2>Casos de uso por tipo de negocio</h2>
|
||||
<div class="grid-casos">
|
||||
<div class="caso">
|
||||
<h3>🍽️ Restaurantes / gastronomía</h3>
|
||||
<p>Menú, horarios, zona para eventos, reservas, opciones para dietas especiales, ubicación y domicilios.</p>
|
||||
</div>
|
||||
<div class="caso">
|
||||
<h3>🩺 Clínicas / consultorios / salud</h3>
|
||||
<p>Servicios, especialidades, si atienden una EPS/seguro, cómo agendar cita, requisitos previos. Solo información administrativa ya publicada — sin diagnósticos ni consejos médicos.</p>
|
||||
</div>
|
||||
<div class="caso">
|
||||
<h3>🏠 Inmobiliarias</h3>
|
||||
<p>Propiedades disponibles por zona/precio, proceso para agendar una visita, requisitos para arrendar/comprar, zonas donde operan.</p>
|
||||
</div>
|
||||
<div class="caso">
|
||||
<h3>🏨 Hoteles / alojamiento</h3>
|
||||
<p>Disponibilidad general, servicios incluidos, políticas de cancelación, cómo llegar, qué hay cerca.</p>
|
||||
</div>
|
||||
<div class="caso">
|
||||
<h3>🛒 E-commerce / tiendas online</h3>
|
||||
<p>Políticas de envío y devolución, tallas/variantes, medios de pago, seguimiento de pedido (según lo publicado en el sitio).</p>
|
||||
</div>
|
||||
<div class="caso">
|
||||
<h3>⚖️ Servicios profesionales</h3>
|
||||
<p>Abogados, contadores, agencias, consultoras: qué servicios prestan, con quién han trabajado, proceso de contratación, tarifas publicadas.</p>
|
||||
</div>
|
||||
<div class="caso">
|
||||
<h3>💻 SaaS / productos digitales</h3>
|
||||
<p>Incluye los propios productos de U-Site (uLink, uVot, uBox, Habla, UIA): funcionalidades, planes, integraciones, documentación pública — reduce tickets de soporte nivel 1.</p>
|
||||
</div>
|
||||
<div class="caso">
|
||||
<h3>🎓 Educación</h3>
|
||||
<p>Colegios, institutos, cursos: programas ofrecidos, proceso de admisión/inscripción, costos, fechas importantes, requisitos.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Lo que uMind todavía no hace</h2>
|
||||
<div class="callout limite">
|
||||
<strong>Para no prometer de más al vender esto:</strong>
|
||||
<ul class="lista-check lista-cruz">
|
||||
<li>No atiende por WhatsApp ni por voz/teléfono todavía — hoy es chat web únicamente. Está en el roadmap.</li>
|
||||
<li>No ejecuta acciones transaccionales (agendar citas en un calendario real, procesar pagos, crear pedidos) — hoy solo informa con base en el contenido del sitio.</li>
|
||||
<li>No lee contenido que no esté publicado en el sitio (PDFs privados, Excels internos, etc.) a menos que se cargue aparte.</li>
|
||||
<li>No reemplaza a un humano en casos sensibles — está instruido para escalar, no para improvisar, en temas delicados (salud, legal, reclamos).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Argumentos de venta</h2>
|
||||
<div class="callout bien">
|
||||
<ul class="lista-check">
|
||||
<li>Instalación en minutos, sin proyecto largo de desarrollo.</li>
|
||||
<li>Cero mantenimiento de guiones: si el sitio cambia, se re-entrena con un clic.</li>
|
||||
<li>Disponible 24/7, sin turnos ni vacaciones.</li>
|
||||
<li>Reduce carga operativa del equipo de atención al cliente en preguntas repetitivas.</li>
|
||||
<li>Mejora la percepción de marca: un sitio que responde al instante se siente más profesional.</li>
|
||||
<li>Escalable por cliente: cada sitio tiene su propio asistente, entrenado solo con su información — nunca se mezcla el conocimiento de un cliente con el de otro.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Modelo de negocio sugerido</h2>
|
||||
<p>
|
||||
<em>(a definir con el equipo comercial)</em> — Un cobro mensual fijo por sitio (incluye hosting del widget y actualizaciones),
|
||||
con la opción de agregar valor a futuro con paquetes de WhatsApp/voz cuando esas fases del roadmap estén listas. Los costos
|
||||
operativos por conversación son bajos (fracciones de centavo de dólar por chat de texto), así que el margen sobre una
|
||||
suscripción mensual es sano incluso en un plan de entrada económico.
|
||||
</p>
|
||||
|
||||
<footer>
|
||||
U-Site S.A.S BIC · uMind · Documento interno para uso comercial
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,243 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>uMind — Guía de onboarding de cliente</title>
|
||||
<style>
|
||||
:root {
|
||||
--verde: #8eb02f;
|
||||
--verde-oscuro: #5a7a1e;
|
||||
--texto: #2b2b2b;
|
||||
--gris: #6b7280;
|
||||
--borde: #e5e7eb;
|
||||
--fondo-suave: #f7f8f5;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color: var(--texto);
|
||||
line-height: 1.6;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px 80px;
|
||||
background: #fff;
|
||||
}
|
||||
header {
|
||||
border-bottom: 3px solid var(--verde);
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
header .badge {
|
||||
display: inline-block;
|
||||
background: var(--verde);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
h1 { font-size: 32px; margin: 0 0 6px; color: #1f2937; }
|
||||
header p.subtitulo { color: var(--gris); font-size: 14px; margin: 0; }
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
color: var(--verde-oscuro);
|
||||
margin-top: 44px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--borde);
|
||||
}
|
||||
h3 { font-size: 15px; margin: 20px 0 6px; color: #1f2937; }
|
||||
p { margin: 10px 0; }
|
||||
code {
|
||||
background: var(--fondo-suave);
|
||||
border: 1px solid var(--borde);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 13px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
color: #b0530f;
|
||||
}
|
||||
pre {
|
||||
background: #1f2937;
|
||||
color: #d1fae5;
|
||||
padding: 16px 18px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 13px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
pre code { background: none; border: none; color: inherit; padding: 0; }
|
||||
ul, ol { padding-left: 22px; }
|
||||
li { margin: 6px 0; }
|
||||
.paso {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin: 14px 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.paso .num {
|
||||
flex: 0 0 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--verde);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.paso .contenido { flex: 1; padding-top: 2px; }
|
||||
.paso .contenido strong { display: block; margin-bottom: 2px; }
|
||||
.paso .contenido p { margin: 2px 0 0; font-size: 14px; color: #374151; }
|
||||
.callout {
|
||||
border-radius: 8px;
|
||||
padding: 16px 18px;
|
||||
margin: 18px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.callout.info { background: #eef6e3; border: 1px solid #c9e0a3; }
|
||||
.callout.alerta { background: #fdf3e7; border: 1px solid #f0d3a3; }
|
||||
.callout strong { display: block; margin-bottom: 6px; }
|
||||
.lista-check { list-style: none; padding-left: 0; }
|
||||
.lista-check li { padding-left: 26px; position: relative; }
|
||||
.lista-check li::before {
|
||||
content: "☐";
|
||||
color: var(--verde-oscuro);
|
||||
font-weight: 700;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 14px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--borde);
|
||||
}
|
||||
th { color: var(--gris); font-weight: 600; text-transform: uppercase; font-size: 11px; }
|
||||
footer {
|
||||
margin-top: 60px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--borde);
|
||||
font-size: 12px;
|
||||
color: var(--gris);
|
||||
text-align: center;
|
||||
}
|
||||
@media print {
|
||||
body { padding: 0; max-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<span class="badge">GUÍA INTERNA</span>
|
||||
<h1>uMind — Guía de onboarding de cliente</h1>
|
||||
<p class="subtitulo">Qué configurar y qué pasos seguir para llevar uMind a un cliente nuevo · Agosto 2026</p>
|
||||
</header>
|
||||
|
||||
<h2>1. Configuración única del sistema</h2>
|
||||
<p>Se hace <strong>una sola vez</strong>, no por cada cliente. Si ya está hecha, sáltate esta sección.</p>
|
||||
|
||||
<div class="paso">
|
||||
<div class="num">1</div>
|
||||
<div class="contenido">
|
||||
<strong>Config de embeddings (OpenAI)</strong>
|
||||
<p>En <code>/app/ai-config</code>, crea una config con provider <strong>OpenAI</strong> y activa el módulo <strong>"uMind — embeddings"</strong>. Necesita su propia API key de OpenAI. La usan <em>todos</em> los clientes para generar los vectores de búsqueda de su base de conocimiento.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paso">
|
||||
<div class="num">2</div>
|
||||
<div class="contenido">
|
||||
<strong>Config de chat (Anthropic recomendado)</strong>
|
||||
<p>Al menos una config activa de chat. Se asigna a cada tenant al crearlo — puedes reutilizar la misma para todos los clientes, o crear una por cliente si más adelante quieres separar costo/uso por cliente.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>2. Información a pedirle al cliente antes de empezar</h2>
|
||||
<table>
|
||||
<tr><th>Dato</th><th>Por qué</th></tr>
|
||||
<tr><td>URL exacta de su sitio</td><td>Es lo que se va a "leer" (crawlear) para entrenar el asistente.</td></tr>
|
||||
<tr><td>Todos los dominios donde corre el sitio</td><td><strong>Crítico:</strong> con y sin <code>www</code>, y staging si aplica. Si falta uno, el widget se bloquea ahí por seguridad.</td></tr>
|
||||
<tr><td>Tono/personalidad deseado</td><td>Opcional — formal, cercano, con emojis, etc. Si no lo dan, se usa un tono neutro por defecto.</td></tr>
|
||||
<tr><td>Mensaje de bienvenida</td><td>Opcional — el saludo que ve el visitante al abrir el chat.</td></tr>
|
||||
<tr><td>Quién instala el script</td><td>Su desarrollador, o alguien de U-Site si administras el sitio.</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>3. Proceso paso a paso en <code>/app/umind</code></h2>
|
||||
|
||||
<div class="paso">
|
||||
<div class="num">1</div>
|
||||
<div class="contenido">
|
||||
<strong>Crear el tenant</strong>
|
||||
<p>Nombre, dominios permitidos, config de IA de chat, tono, mensaje de bienvenida.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paso">
|
||||
<div class="num">2</div>
|
||||
<div class="contenido">
|
||||
<strong>Ingestar el sitio</strong>
|
||||
<p>Entra al tenant → pestaña "Base de conocimiento" → pega la URL del sitio → clic en Ingestar. Espera a que el estado pase de "procesando" a "listo" (puede tardar varios minutos según el tamaño del sitio).</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paso">
|
||||
<div class="num">3</div>
|
||||
<div class="contenido">
|
||||
<strong>Copiar el código de instalación</strong>
|
||||
<p>Desde la ficha del tenant, copia el snippet:</p>
|
||||
<pre><code><script src="https://admin.u-site.app/widget/umind.js" data-site="SITE_KEY" defer></script></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paso">
|
||||
<div class="num">4</div>
|
||||
<div class="contenido">
|
||||
<strong>Entregarlo al cliente</strong>
|
||||
<p>Que lo peguen justo antes de <code></body></code> en su sitio (o lo pega quien administre el sitio si es un proyecto de U-Site).</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paso">
|
||||
<div class="num">5</div>
|
||||
<div class="contenido">
|
||||
<strong>Probar en el sitio real</strong>
|
||||
<p>Entra al sitio del cliente y hazle un par de preguntas al widget para validar que responde bien antes de darlo por "en producción".</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>4. Antes de vender a un cliente puntual — revisa esto</h2>
|
||||
<div class="callout alerta">
|
||||
<strong>Limitaciones a tener en cuenta:</strong>
|
||||
<ul class="lista-check">
|
||||
<li><strong>Sitios 100% React/Vue/Angular sin renderizado en servidor (SPA):</strong> el crawler solo lee el HTML que devuelve el servidor, no ejecuta JavaScript. Si el contenido del sitio se carga todo por JS, la ingesta puede salir casi vacía — revisa el sitio (o pide que lo validen) antes de prometer el servicio.</li>
|
||||
<li><strong>No hay carga de PDF/Word todavía:</strong> solo se ingesta desde una URL pública. Si el cliente quiere que el asistente conozca un PDF de precios que no está publicado como página web, eso no está soportado hoy.</li>
|
||||
<li><strong>Sitios muy grandes:</strong> ajustar el número máximo de páginas al ingestar (por defecto 30) si el sitio tiene cientos de páginas.</li>
|
||||
<li><strong>Sin panel de costos por cliente todavía:</strong> si se factura por consumo real en vez de tarifa plana, hoy no hay forma de ver cuánto gastó cada tenant en la plataforma.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Checklist rápido</h2>
|
||||
<div class="callout info">
|
||||
<ul class="lista-check">
|
||||
<li>Config global de embeddings (OpenAI) creada — una sola vez</li>
|
||||
<li>Config global de chat (Anthropic) creada — una sola vez</li>
|
||||
<li>URL y todos los dominios del cliente confirmados</li>
|
||||
<li>Tenant creado en /app/umind</li>
|
||||
<li>Sitio ingestado y en estado "listo"</li>
|
||||
<li>Snippet copiado y entregado</li>
|
||||
<li>Widget probado en el sitio real</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
U-Site S.A.S BIC · uMind · Guía interna de onboarding
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -51,6 +51,14 @@ token:
|
||||
app_jwt_secret: SECRET_APP
|
||||
api_jwt_secret: SECRET_API
|
||||
expires_in: 31536000
|
||||
# Conexiones OAuth de uMind (Gmail/Outlook) — vacío hasta crear las apps en
|
||||
# Google Cloud Console / Azure. Se completan por variable de entorno
|
||||
# (GOOGLE_OAUTH_CLIENT_ID, etc.), no acá.
|
||||
oauth:
|
||||
google_client_id: ""
|
||||
google_client_secret: ""
|
||||
ms_client_id: ""
|
||||
ms_client_secret: ""
|
||||
jwt:
|
||||
app:
|
||||
secret: SECRET_APP
|
||||
|
||||
@@ -55,6 +55,14 @@ token:
|
||||
app_jwt_secret: SECRET_APP
|
||||
api_jwt_secret: SECRET_API
|
||||
expires_in: 31536000
|
||||
# Conexiones OAuth de uMind (Gmail/Outlook) — vacío hasta crear las apps en
|
||||
# Google Cloud Console / Azure. Se completan por variable de entorno
|
||||
# (GOOGLE_OAUTH_CLIENT_ID, etc.), no acá.
|
||||
oauth:
|
||||
google_client_id: ""
|
||||
google_client_secret: ""
|
||||
ms_client_id: ""
|
||||
ms_client_secret: ""
|
||||
jwt:
|
||||
app:
|
||||
secret: SECRET_APP
|
||||
|
||||
@@ -28,6 +28,7 @@ type AppConfig struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
Token Token `yaml:"token"`
|
||||
OAuth OAuthConfig `yaml:"oauth"`
|
||||
Profiler ProfilerConfig `yaml:"profiler"`
|
||||
Flash *flash.Flash
|
||||
ConfigFile string
|
||||
@@ -40,6 +41,7 @@ func (cfg *AppConfig) Setup() {
|
||||
fmt.Println(err)
|
||||
os.Exit(2)
|
||||
}
|
||||
cfg.VerificarSecretos()
|
||||
cfg.Server.LoadPath()
|
||||
cfg.View.Load(cfg.Server.Path)
|
||||
cfg.Mail.View = &cfg.View
|
||||
@@ -94,6 +96,8 @@ func (cfg *AppConfig) LoadComponents() {
|
||||
|
||||
func (cfg *AppConfig) LoadStatic() {
|
||||
cfg.Server.Static("/websocket", "./resources/views/websocket.html")
|
||||
// El guard se registra antes que el estático para que corra primero.
|
||||
cfg.Server.Use("/uploads", uploadsProtegidos)
|
||||
cfg.Server.Static("/uploads", "./uploads", fiber.Static{
|
||||
ByteRange: true,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package config
|
||||
|
||||
// OAuthConfig trae las credenciales de las apps OAuth para conectar cuentas
|
||||
// de correo (uMind: enviar/leer correo en nombre de un tenant). Sin
|
||||
// env-default, igual que los secretos JWT — se cargan por variable de
|
||||
// entorno, no quedan escritos en config.yml.
|
||||
type OAuthConfig struct {
|
||||
GoogleClientID string `mapstructure:"GOOGLE_OAUTH_CLIENT_ID" yaml:"google_client_id" env:"GOOGLE_OAUTH_CLIENT_ID"`
|
||||
GoogleClientSecret string `mapstructure:"GOOGLE_OAUTH_CLIENT_SECRET" yaml:"google_client_secret" env:"GOOGLE_OAUTH_CLIENT_SECRET"`
|
||||
MSClientID string `mapstructure:"MS_OAUTH_CLIENT_ID" yaml:"ms_client_id" env:"MS_OAUTH_CLIENT_ID"`
|
||||
MSClientSecret string `mapstructure:"MS_OAUTH_CLIENT_SECRET" yaml:"ms_client_secret" env:"MS_OAUTH_CLIENT_SECRET"`
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Valores que estuvieron publicados en el repositorio y deben considerarse
|
||||
// comprometidos. Si siguen en uso, cualquiera que haya visto el repo puede
|
||||
// fabricarse una sesión de administrador o descifrar las contraseñas guardadas.
|
||||
const (
|
||||
jwtAppComprometido = "SECRET_APP"
|
||||
jwtApiComprometido = "SECRET_API"
|
||||
appKeyComprometida = "1894cde6c936a294a478cff0a9227fd276d86df6573b51af5dc59c9064edf426"
|
||||
)
|
||||
|
||||
// VerificarSecretos se ejecuta al arrancar. Los secretos JWT comprometidos
|
||||
// abortan el arranque (permiten suplantar a cualquier usuario y el único costo
|
||||
// de rotarlos es volver a iniciar sesión). La clave de cifrado solo advierte,
|
||||
// porque cambiarla invalida las contraseñas de integraciones ya guardadas y eso
|
||||
// requiere volver a capturarlas a mano desde el panel.
|
||||
func (cfg *AppConfig) VerificarSecretos() {
|
||||
var criticos []string
|
||||
|
||||
if esSecretoDebil(cfg.Token.AppJwtSecret, jwtAppComprometido) {
|
||||
criticos = append(criticos, "APP_JWT_SECRET")
|
||||
}
|
||||
if esSecretoDebil(cfg.Token.ApiJwtSecret, jwtApiComprometido) {
|
||||
criticos = append(criticos, "API_JWT_SECRET")
|
||||
}
|
||||
|
||||
if len(criticos) > 0 {
|
||||
log.Printf("[SEGURIDAD] Los siguientes secretos siguen con el valor publicado en el repositorio: %s", strings.Join(criticos, ", "))
|
||||
log.Printf("[SEGURIDAD] Con ese valor cualquiera puede firmarse una cookie de sesión y entrar como administrador.")
|
||||
log.Printf("[SEGURIDAD] Define variables de entorno con valores nuevos y aleatorios antes de arrancar, por ejemplo:")
|
||||
for _, nombre := range criticos {
|
||||
log.Printf("[SEGURIDAD] %s=$(openssl rand -hex 32)", nombre)
|
||||
}
|
||||
log.Fatalf("[SEGURIDAD] Arranque abortado para no exponer el sistema. Al rotarlos, las sesiones abiertas se cierran y hay que volver a iniciar sesión.")
|
||||
}
|
||||
|
||||
if cfg.Server.Key == "" || cfg.Server.Key == appKeyComprometida {
|
||||
log.Printf("[SEGURIDAD] APP_KEY tiene el valor por defecto publicado en el repositorio.")
|
||||
log.Printf("[SEGURIDAD] Con esa clave, cualquiera con acceso a la base de datos puede descifrar las contraseñas SMTP y de integraciones.")
|
||||
log.Printf("[SEGURIDAD] Define APP_KEY=$(openssl rand -hex 32) y vuelve a guardar las contraseñas de SMTP e integraciones desde el panel")
|
||||
log.Printf("[SEGURIDAD] (al cambiar la clave, las contraseñas cifradas con la anterior dejan de poder descifrarse).")
|
||||
}
|
||||
}
|
||||
|
||||
// esSecretoDebil indica si el secreto está vacío o es el valor comprometido.
|
||||
func esSecretoDebil(valor, comprometido string) bool {
|
||||
v := strings.TrimSpace(valor)
|
||||
return v == "" || v == comprometido
|
||||
}
|
||||
|
||||
// SecretoDesdeEntorno lee un secreto del entorno con un valor por defecto.
|
||||
// Se usa para credenciales que no deben vivir en config.yml.
|
||||
func SecretoDesdeEntorno(nombre, porDefecto string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(nombre)); v != "" {
|
||||
return v
|
||||
}
|
||||
return porDefecto
|
||||
}
|
||||
+11
-8
@@ -9,13 +9,16 @@ import (
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
Hash string `json:"token"`
|
||||
Expire int64 `mapstructure:"JWT_EXPIRE" json:"expires_in" yaml:"expires_in"`
|
||||
AppJwtSecret string `mapstructure:"APP_JWT_SECRET" yaml:"app_jwt_secret"`
|
||||
ApiJwtSecret string `mapstructure:"API_JWT_SECRET" yaml:"api_jwt_secret"`
|
||||
Hash string `json:"token"`
|
||||
Expire int64 `mapstructure:"JWT_EXPIRE" json:"expires_in" yaml:"expires_in"`
|
||||
// Los secretos JWT firman las cookies de sesión: si se filtran, cualquiera
|
||||
// puede fabricarse una sesión de administrador válida. El tag env: permite
|
||||
// definirlos por variable de entorno sin dejarlos escritos en config.yml.
|
||||
AppJwtSecret string `mapstructure:"APP_JWT_SECRET" yaml:"app_jwt_secret" env:"APP_JWT_SECRET"`
|
||||
ApiJwtSecret string `mapstructure:"API_JWT_SECRET" yaml:"api_jwt_secret" env:"API_JWT_SECRET"`
|
||||
}
|
||||
|
||||
//CreateToken authenticates the user
|
||||
// CreateToken authenticates the user
|
||||
func (t *Token) CreateToken(c *fiber.Ctx, userID uint, secret string, expire ...int64) (*Token, error) {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
|
||||
@@ -47,7 +50,7 @@ func (t *Token) CreateToken(c *fiber.Ctx, userID uint, secret string, expire ...
|
||||
return t, nil
|
||||
}
|
||||
|
||||
//ParseToken returns the users id or error
|
||||
// ParseToken returns the users id or error
|
||||
func (t *Token) ParseToken(c *fiber.Ctx, secret string) (uint, error) {
|
||||
tokenString := c.Cookies("Verify-Rest-Token")
|
||||
|
||||
@@ -74,12 +77,12 @@ func (t *Token) ParseToken(c *fiber.Ctx, secret string) (uint, error) {
|
||||
return uint(claims["id"].(float64)), nil
|
||||
}
|
||||
|
||||
//DeleteToken deletes the jwt token
|
||||
// DeleteToken deletes the jwt token
|
||||
func (t *Token) DeleteToken(c *fiber.Ctx) {
|
||||
c.ClearCookie("Verify-Rest-Token")
|
||||
}
|
||||
|
||||
//RefreshToken refreshes the token
|
||||
// RefreshToken refreshes the token
|
||||
func (t *Token) RefreshToken(c *fiber.Ctx, secret string) (*Token, error) {
|
||||
u, err := t.ParseToken(c, secret)
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package config
|
||||
|
||||
import "github.com/gofiber/fiber/v2"
|
||||
|
||||
// UploadsGuard decide si una petición puede leer archivos de /uploads.
|
||||
// Se inyecta desde main.go porque el paquete config no puede importar pkg/auth
|
||||
// (sería una dependencia circular vía app). Se consulta en cada petición, no al
|
||||
// registrar la ruta, así que puede definirse después de cargar la configuración.
|
||||
//
|
||||
// Mientras esté en nil, /uploads queda cerrado: es preferible que un archivo no
|
||||
// cargue a que quede expuesto sin autenticación.
|
||||
var UploadsGuard func(*fiber.Ctx) bool
|
||||
|
||||
// uploadsProtegidos bloquea el acceso anónimo a los archivos subidos.
|
||||
// Antes /uploads se servía como estático público: sabiendo (o adivinando) la
|
||||
// ruta se podía descargar el RUT de un cliente, una factura o un entregable sin
|
||||
// iniciar sesión. Los clientes del portal siguen usando los endpoints de
|
||||
// descarga dedicados, que además validan que el archivo les pertenezca.
|
||||
func uploadsProtegidos(c *fiber.Ctx) error {
|
||||
if UploadsGuard != nil && UploadsGuard(c) {
|
||||
return c.Next()
|
||||
}
|
||||
// 404 en vez de 401 para no confirmar si el archivo existe.
|
||||
return c.Status(fiber.StatusNotFound).SendString("Not found")
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
<title>Contrato API v1</title>
|
||||
<style>
|
||||
/* ── Tokens ───────────────────────────────────────────────────────────────
|
||||
Paleta anclada al verde de marca de U-Site (#8eb02f) que ya usa el panel,
|
||||
el widget y los correos. Neutros con sesgo oliva para que el acento no
|
||||
flote sobre un gris ajeno. Los colores semánticos (crítico / atención /
|
||||
confirmado) son un juego aparte del acento, a propósito. */
|
||||
:root {
|
||||
--ground: #f6f6f2;
|
||||
--surface: #ffffff;
|
||||
--surface-sunk: #f0f1ea;
|
||||
--line: #e0e1d6;
|
||||
--line-strong: #c9cbba;
|
||||
--text: #1c1e18;
|
||||
--text-soft: #5a5d51;
|
||||
--text-faint: #86897a;
|
||||
--accent: #63801c;
|
||||
--accent-soft: #eef3dd;
|
||||
--crit: #a32a1e;
|
||||
--crit-soft: #fbe9e6;
|
||||
--warn: #8a5a06;
|
||||
--warn-soft: #fbf0d9;
|
||||
--ok: #2f6b3f;
|
||||
--ok-soft: #e6f1e6;
|
||||
--code-bg: #1a1c16;
|
||||
--code-text: #e8eadd;
|
||||
--code-dim: #8f947f;
|
||||
--shadow: 0 1px 2px rgba(28,30,24,.06), 0 8px 24px -12px rgba(28,30,24,.14);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--ground: #14150f;
|
||||
--surface: #1c1e17;
|
||||
--surface-sunk: #24261d;
|
||||
--line: #32352a;
|
||||
--line-strong: #474b3c;
|
||||
--text: #e9ebdf;
|
||||
--text-soft: #b0b4a2;
|
||||
--text-faint: #82866f;
|
||||
--accent: #a8c94f;
|
||||
--accent-soft: #2a3118;
|
||||
--crit: #f08a7c;
|
||||
--crit-soft: #38201c;
|
||||
--warn: #e5b45c;
|
||||
--warn-soft: #362a13;
|
||||
--ok: #7fc08d;
|
||||
--ok-soft: #1d2f22;
|
||||
--code-bg: #0e0f0a;
|
||||
--code-text: #e8eadd;
|
||||
--code-dim: #7b8069;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 10px 30px -14px rgba(0,0,0,.7);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--ground: #14150f;
|
||||
--surface: #1c1e17;
|
||||
--surface-sunk: #24261d;
|
||||
--line: #32352a;
|
||||
--line-strong: #474b3c;
|
||||
--text: #e9ebdf;
|
||||
--text-soft: #b0b4a2;
|
||||
--text-faint: #82866f;
|
||||
--accent: #a8c94f;
|
||||
--accent-soft: #2a3118;
|
||||
--crit: #f08a7c;
|
||||
--crit-soft: #38201c;
|
||||
--warn: #e5b45c;
|
||||
--warn-soft: #362a13;
|
||||
--ok: #7fc08d;
|
||||
--ok-soft: #1d2f22;
|
||||
--code-bg: #0e0f0a;
|
||||
--code-text: #e8eadd;
|
||||
--code-dim: #7b8069;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 10px 30px -14px rgba(0,0,0,.7);
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--text);
|
||||
font-family: ui-sans-serif, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.wrap { max-width: 60rem; margin: 0 auto; padding: 3rem 1.5rem 6rem; }
|
||||
|
||||
/* ── Encabezado ─────────────────────────────────────────────────────────── */
|
||||
.eyebrow {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .6875rem; letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--accent); margin: 0 0 .75rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(1.9rem, 1.3rem + 2.2vw, 2.75rem);
|
||||
line-height: 1.1; letter-spacing: -.022em; font-weight: 660;
|
||||
margin: 0 0 .85rem; text-wrap: balance;
|
||||
}
|
||||
.standfirst {
|
||||
font-size: 1.0625rem; color: var(--text-soft);
|
||||
max-width: 46rem; margin: 0 0 2rem;
|
||||
}
|
||||
.standfirst strong { color: var(--text); font-weight: 620; }
|
||||
|
||||
/* ── Resumen ────────────────────────────────────────────────────────────── */
|
||||
.tally { display: flex; flex-wrap: wrap; gap: .625rem; margin-bottom: 1rem; }
|
||||
.tally-item {
|
||||
display: flex; align-items: baseline; gap: .5rem;
|
||||
background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: .5rem; padding: .625rem .875rem; box-shadow: var(--shadow);
|
||||
}
|
||||
.tally-n {
|
||||
font-size: 1.375rem; font-weight: 680; line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tally-l { font-size: .8125rem; color: var(--text-soft); }
|
||||
.tally-item.is-crit { border-color: var(--crit); }
|
||||
.tally-item.is-crit .tally-n { color: var(--crit); }
|
||||
.tally-item.is-warn .tally-n { color: var(--warn); }
|
||||
.tally-item.is-ok .tally-n { color: var(--ok); }
|
||||
|
||||
.meta {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .75rem; color: var(--text-faint);
|
||||
border-top: 1px solid var(--line); padding-top: 1rem; margin-bottom: 2.75rem;
|
||||
}
|
||||
.meta code { background: none; padding: 0; color: var(--text-soft); }
|
||||
|
||||
/* ── Secciones numeradas ────────────────────────────────────────────────
|
||||
La numeración no es decorativa: el otro equipo mandó las preguntas
|
||||
numeradas del 1 al 10 y así se responden en el mismo orden. */
|
||||
.q {
|
||||
display: grid; grid-template-columns: 3.25rem 1fr; gap: 0 1.25rem;
|
||||
padding: 1.75rem 0; border-top: 1px solid var(--line);
|
||||
}
|
||||
.q-num {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .875rem; font-weight: 600; color: var(--text-faint);
|
||||
font-variant-numeric: tabular-nums; padding-top: .3rem;
|
||||
}
|
||||
.q-body { min-width: 0; }
|
||||
.q h2 {
|
||||
font-size: 1.1875rem; line-height: 1.3; letter-spacing: -.012em;
|
||||
font-weight: 640; margin: 0 0 .6rem; text-wrap: balance;
|
||||
}
|
||||
.q p { margin: 0 0 .85rem; }
|
||||
.q p:last-child { margin-bottom: 0; }
|
||||
.q ul { margin: 0 0 .85rem; padding-left: 1.15rem; }
|
||||
.q li { margin-bottom: .3rem; }
|
||||
|
||||
/* Barra de severidad a la izquierda del número */
|
||||
.q.sev-crit .q-num { color: var(--crit); }
|
||||
.q.sev-warn .q-num { color: var(--warn); }
|
||||
.q.sev-ok .q-num { color: var(--ok); }
|
||||
|
||||
.chip {
|
||||
display: inline-block; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .6875rem; letter-spacing: .06em; text-transform: uppercase;
|
||||
padding: .2rem .5rem; border-radius: .3125rem; margin-bottom: .55rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip-crit { background: var(--crit-soft); color: var(--crit); }
|
||||
.chip-warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.chip-ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.chip-info { background: var(--surface-sunk); color: var(--text-soft); }
|
||||
|
||||
/* ── Código ─────────────────────────────────────────────────────────────── */
|
||||
pre {
|
||||
background: var(--code-bg); color: var(--code-text);
|
||||
border-radius: .5rem; padding: .875rem 1rem; margin: 0 0 .85rem;
|
||||
overflow-x: auto; font-size: .8125rem; line-height: 1.55;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
pre .c { color: var(--code-dim); }
|
||||
pre .hl { color: #d9e88a; }
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .875em; background: var(--surface-sunk);
|
||||
padding: .1rem .3rem; border-radius: .25rem;
|
||||
}
|
||||
|
||||
.cite {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .75rem; color: var(--text-faint); margin: -.35rem 0 .85rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
border-left: 2px solid var(--accent); background: var(--accent-soft);
|
||||
padding: .75rem .9rem; border-radius: 0 .375rem .375rem 0;
|
||||
margin: 0 0 .85rem; font-size: .9375rem;
|
||||
}
|
||||
.note.is-crit { border-left-color: var(--crit); background: var(--crit-soft); }
|
||||
.note p { margin: 0; }
|
||||
.note p + p { margin-top: .5rem; }
|
||||
|
||||
/* ── Tablas ─────────────────────────────────────────────────────────────── */
|
||||
.scroll { overflow-x: auto; margin: 0 0 .85rem; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: .875rem; min-width: 32rem; }
|
||||
th, td { text-align: left; padding: .5rem .7rem; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
th {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .6875rem; letter-spacing: .07em; text-transform: uppercase;
|
||||
color: var(--text-faint); font-weight: 600;
|
||||
border-bottom-color: var(--line-strong);
|
||||
}
|
||||
td code { font-size: .8125rem; }
|
||||
|
||||
/* ── Cierre ─────────────────────────────────────────────────────────────── */
|
||||
.todo {
|
||||
margin-top: 3rem; background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: .625rem; padding: 1.5rem 1.75rem; box-shadow: var(--shadow);
|
||||
}
|
||||
.todo h2 { font-size: 1.125rem; font-weight: 640; margin: 0 0 .35rem; letter-spacing: -.01em; }
|
||||
.todo > p { color: var(--text-soft); font-size: .9375rem; margin: 0 0 1.1rem; }
|
||||
.todo ol { margin: 0; padding-left: 1.15rem; }
|
||||
.todo li { margin-bottom: .7rem; }
|
||||
.todo li:last-child { margin-bottom: 0; }
|
||||
.todo li strong { font-weight: 620; }
|
||||
|
||||
a { color: var(--accent); text-underline-offset: 2px; }
|
||||
a:focus-visible, [tabindex]:focus-visible {
|
||||
outline: 2px solid var(--accent); outline-offset: 2px; border-radius: .2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 34rem) {
|
||||
.q { grid-template-columns: 1fr; gap: 0; }
|
||||
.q-num { padding-top: 0; margin-bottom: .35rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<p class="eyebrow">Referencia de integración · admin.u-site.app</p>
|
||||
<h1>Contrato real de la API v1</h1>
|
||||
<p class="standfirst">
|
||||
Las 10 preguntas del equipo integrador, respondidas <strong>leyendo el código del
|
||||
servidor</strong> en vez de inferirlas desde el cliente. Cada respuesta cita archivo y
|
||||
línea para que se pueda auditar. Una es crítica y explica por qué la integración
|
||||
probablemente nunca autenticó.
|
||||
</p>
|
||||
|
||||
<div class="tally">
|
||||
<div class="tally-item is-crit"><span class="tally-n">1</span><span class="tally-l">crítica</span></div>
|
||||
<div class="tally-item is-warn"><span class="tally-n">2</span><span class="tally-l">requieren atención</span></div>
|
||||
<div class="tally-item is-ok"><span class="tally-n">7</span><span class="tally-l">contrato confirmado</span></div>
|
||||
<div class="tally-item"><span class="tally-n">4</span><span class="tally-l">arreglos de nuestro lado</span></div>
|
||||
</div>
|
||||
|
||||
<p class="meta">
|
||||
Base de todos los endpoints: <code>https://admin.u-site.app/api/v1/…</code><br>
|
||||
Montaje: <code>rest/routes/routes.go:10</code> monta <code>/api</code> · <code>rest/routes/api.go:38</code> agrega <code>v1</code>
|
||||
</p>
|
||||
|
||||
<!-- 1 -->
|
||||
<section class="q sev-ok">
|
||||
<div class="q-num">01</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-ok">Su lectura es correcta</span>
|
||||
<h2><code>expires_in</code> es un timestamp Unix absoluto</h2>
|
||||
<p>El campo se reusa: entra como duración en segundos y sale como timestamp.</p>
|
||||
<pre><span class="c">// config/token.go:26-49</span>
|
||||
t.Expire = ninetyYears <span class="c">// duración, en segundos</span>
|
||||
expiresIn := time.Now().Add(
|
||||
time.Duration(t.Expire)*time.Second).Unix()
|
||||
claims["exp"] = expiresIn
|
||||
t.Expire = <span class="hl">expiresIn</span> <span class="c">// ← se SOBREESCRIBE</span></pre>
|
||||
<p>Lo que viaja en la respuesta es <code>expiresIn</code>: segundos desde epoch.</p>
|
||||
<div class="note is-crit">
|
||||
<p><strong>Pero hay algo más grave.</strong> <code>Login</code> llama a
|
||||
<code>CreateToken</code> sin pasarle vencimiento (<code>pkg/auth/user.go:98</code>),
|
||||
así que aplica el default: <strong>90 años</strong>.</p>
|
||||
<p>En la práctica ese token nunca expira, y toda la lógica de caché y renovación
|
||||
del cliente no se ejerce jamás. Es un arreglo nuestro, no de ustedes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2 -->
|
||||
<section class="q sev-ok">
|
||||
<div class="q-num">02</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-ok">Contrato confirmado</span>
|
||||
<h2>Respuesta de <code>POST /oauth/token</code></h2>
|
||||
<pre>{
|
||||
"<span class="hl">token</span>": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"<span class="hl">expires_in</span>": 4626547200
|
||||
}</pre>
|
||||
<p class="cite">rest/controllers/api/auth_controller.go:42-45</p>
|
||||
<ul>
|
||||
<li>El campo es <code>token</code>, <strong>no</strong> <code>access_token</code>.</li>
|
||||
<li><code>expires_in</code> está en el nivel raíz.</li>
|
||||
<li>No hay <code>token_type</code>, ni <code>refresh_token</code>, ni envoltorio.</li>
|
||||
</ul>
|
||||
<p>Error de credenciales, siempre HTTP 401:</p>
|
||||
<pre>{ "error": true, "message": "Invalid Credentials" }</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3 -->
|
||||
<section class="q sev-warn">
|
||||
<div class="q-num">03</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-warn">Plano, con una excepción</span>
|
||||
<h2>QR y VCF devuelven JSON sin envoltorio</h2>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Endpoint</th><th>Método</th><th>Respuesta</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>/generate-qr-tmp</code></td><td>POST</td><td><code>{"url": "https://…"}</code></td></tr>
|
||||
<tr><td><code>/generate-url-qr</code></td><td>POST</td><td><code>{"url": "https://…"}</code></td></tr>
|
||||
<tr><td><code>/generate-vcf</code></td><td>POST</td><td><code>{"success": true, "url": "https://…"}</code></td></tr>
|
||||
<tr><td><code>/generate-qr</code></td><td>POST</td><td><strong>No es JSON</strong> — imagen binaria <code>image/webp</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="cite">vcard_qr.go:133-135 · vcard_vcf.go:103-106 · vcard_qr.go:169-170</p>
|
||||
<p>Ojo con el último: <code>generate-qr</code> responde bytes de imagen. Un
|
||||
<code>json_decode</code> sobre eso falla siempre.</p>
|
||||
<p>Errores: <code>{"error": "Datos inválidos"}</code> con 400, o
|
||||
<code>{"error": "<detalle>"}</code> con 500.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4 -->
|
||||
<section class="q sev-ok">
|
||||
<div class="q-num">04</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-ok">Sí, el envoltorio raro es real</span>
|
||||
<h2>dLocal devuelve JSON codificado como string</h2>
|
||||
<p>Nadie lo adivinó mal. La doble codificación existe:</p>
|
||||
<pre><span class="c">// rest/controllers/api/dlocal_controller.go:42-45</span>
|
||||
return c.Status(200).JSON(fiber.Map{
|
||||
"message": "Plan creado exitosamente.",
|
||||
"response": <span class="hl">string(response)</span>, <span class="c">// ← el JSON, como STRING</span>
|
||||
})</pre>
|
||||
<p>Lo que llega:</p>
|
||||
<pre>{
|
||||
"message": "Plan creado exitosamente.",
|
||||
"response": "<span class="hl">{\"id\":\"PLAN-123\",\"status\":\"active\"}</span>"
|
||||
}</pre>
|
||||
<p>Hay que hacer <code>json_decode</code> <strong>dos veces</strong>: una del cuerpo y
|
||||
otra del campo <code>response</code>. Aplica igual a los 7 endpoints dLocal
|
||||
(líneas 44, 77, 110, 137, 162, 188, 221).</p>
|
||||
<p>El texto de <code>message</code> cambia según el endpoint, así que no conviene
|
||||
usarlo para decidir nada.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5 -->
|
||||
<section class="q sev-warn">
|
||||
<div class="q-num">05</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-warn">Hay dos textos, no uno</span>
|
||||
<h2>El reintento automático cubre solo la mitad de los casos</h2>
|
||||
<pre><span class="c">// rest/middlewares/auth.go:271-286</span>
|
||||
token := c.Cookies("Verify-Rest-Token")
|
||||
if token == "" {
|
||||
return c.Status(401).JSON(<span class="hl">"Token not found"</span>) <span class="c">// falta la cookie</span>
|
||||
}
|
||||
… ErrorHandler:
|
||||
return ctx.Status(401).JSON(<span class="hl">"Invalid Attempt"</span>) <span class="c">// cookie inválida o vencida</span></pre>
|
||||
<p>Los dos son <strong>JSON string, con comillas incluidas</strong> — el cuerpo
|
||||
literal es <code>"Invalid Attempt"</code>, 16 bytes, no el texto pelado.</p>
|
||||
<div class="note">
|
||||
<p><strong>Recomendación:</strong> reintentar ante cualquier 401, sin mirar el
|
||||
texto. Es más robusto y no depende de una redacción que puede cambiar.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 6 -->
|
||||
<section class="q sev-ok">
|
||||
<div class="q-num">06</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-ok">Una sola raíz</span>
|
||||
<h2>No hay hosts separados por grupo</h2>
|
||||
<p>Todo cuelga de <code>https://admin.u-site.app/api/v1/</code>. Lo correcto es
|
||||
guardar <strong>la raíz</strong> y concatenar la ruta, en vez de guardar la URL del
|
||||
token y derivar las demás con <code>str_replace</code>.</p>
|
||||
<pre>POST /api/v1/oauth/token
|
||||
POST /api/v1/generate-qr-tmp
|
||||
POST /api/v1/generate-qr <span class="c">(imagen)</span>
|
||||
POST /api/v1/generate-vcf
|
||||
POST /api/v1/generate-url-qr
|
||||
POST /api/v1/dlocal/subscription/crear-plan
|
||||
GET /api/v1/dlocal/subscription/ver-plan/:planID
|
||||
PATCH /api/v1/dlocal/subscription/actualizar-plan/:planID
|
||||
GET /api/v1/dlocal/subscription/plan/all
|
||||
PATCH /api/v1/dlocal/subscription/plan/:planId/subscription/:subscriptionId/deactivate
|
||||
GET /api/v1/dlocal/subscription/:subscriptionId/execution/:invoiceId
|
||||
POST /api/v1/dlocal/payment/crear-pago
|
||||
POST /api/v1/rapyd/wallet/create</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 7 -->
|
||||
<section class="q sev-crit">
|
||||
<div class="q-num">07</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-crit">Crítico</span>
|
||||
<h2>El header <code>Cookie</code> es obligatorio, y hoy está roto</h2>
|
||||
<p>La API <strong>no acepta Bearer</strong>. Autentica exclusivamente por cookie:</p>
|
||||
<pre><span class="c">// rest/middlewares/auth.go:272-281</span>
|
||||
token := c.Cookies(<span class="hl">"Verify-Rest-Token"</span>)
|
||||
if token == "" { return c.Status(401).JSON("Token not found") }
|
||||
TokenLookup: <span class="hl">"cookie:Verify-Rest-Token"</span></pre>
|
||||
<div class="note is-crit">
|
||||
<p>Con el placeholder literal <code>...</code> sin completar, <strong>los 12
|
||||
endpoints autenticados devuelven 401</strong>. Si algo funciona hoy, es porque el
|
||||
cliente HTTP tiene cookie jar y reusa la cookie que <code>POST /oauth/token</code>
|
||||
deja seteada en la respuesta (<code>config/token.go:40-47</code>).</p>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Mandar el token en el body o como <code>Authorization: Bearer</code>
|
||||
<strong>no autentica nada</strong>.</li>
|
||||
<li><code>session_id</code> <strong>no hace falta</strong>: el middleware no lo
|
||||
mira. Se puede quitar.</li>
|
||||
</ul>
|
||||
<p>Lo correcto: tomar el <code>token</code> de la respuesta de <code>oauth/token</code>
|
||||
y mandarlo como <code>Cookie: Verify-Rest-Token=<token></code>, o dejar que el
|
||||
cliente maneje cookies solo.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 8 -->
|
||||
<section class="q sev-ok">
|
||||
<div class="q-num">08</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-info">No aplica</span>
|
||||
<h2>No hay control por IP en <code>/api/v1</code></h2>
|
||||
<p>Solo se valida la cookie JWT. No hay allowlist; no hace falta registrar la IP
|
||||
del servidor de producción.</p>
|
||||
<p>Sí existe validación por IP, pero en <strong>otra</strong> API:
|
||||
<code>/api/v2</code> usa <code>ApiKey</code> con IP obligatoria y scopes, y
|
||||
<code>/api/v1/pagos-externos</code> usa <code>AuthServicioPago</code> — ambas
|
||||
excluidas de <code>AuthApi</code> (<code>auth.go:266-268</code>).</p>
|
||||
<div class="note">
|
||||
<p>Si prefieren un esquema con IP fija y token que no viva en una cookie,
|
||||
<strong><code>/api/v2</code> es el camino</strong>, y conviene migrar ahí en vez de
|
||||
arreglar el actual.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 9 -->
|
||||
<section class="q sev-warn">
|
||||
<div class="q-num">09</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-warn">Fuera de este documento</span>
|
||||
<h2>Credenciales</h2>
|
||||
<p>No van acá. Son las de un usuario real de la tabla <code>users</code>
|
||||
(<code>login.CheckLogin()</code>, <code>auth_controller.go:28</code>). Que se
|
||||
entreguen por un canal seguro.</p>
|
||||
<p>Dado que llevan 15 meses sin rotar, conviene <strong>crear un usuario dedicado a
|
||||
la integración</strong> en vez de reusar el de una persona.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 10 -->
|
||||
<section class="q sev-ok">
|
||||
<div class="q-num">10</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-info">Existen, pero no para esto</span>
|
||||
<h2>No hay callback saliente hacia ustedes</h2>
|
||||
<p>Hay webhooks entrantes de pago ya montados
|
||||
(<code>rest/routes/publicas.go:22-33</code>): <code>/webhooks/bold</code>,
|
||||
<code>/webhooks/dlocal</code>, <code>/webhooks/paypal</code>, más Coolify y Telegram.</p>
|
||||
<p>Pero sirven para que <strong>la pasarela nos avise a nosotros</strong>, no para
|
||||
avisarle a un tercero. Un callback hacia su sistema es desarrollo nuevo.</p>
|
||||
<p>Mientras tanto, la alternativa ya construida es
|
||||
<code>/api/v1/pagos-externos</code>, pensada justo para apps de terceros:</p>
|
||||
<pre>GET /api/v1/pagos-externos/pasarelas
|
||||
POST /api/v1/pagos-externos/solicitar
|
||||
GET /api/v1/pagos-externos/:referencia/estado</pre>
|
||||
<div class="note">
|
||||
<p>Usa token Bearer + IP (no cookie), así que evita todo el problema del punto 07.
|
||||
<strong>Para una integración nueva, recomendamos esta</strong> y no los endpoints
|
||||
dLocal directos.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="todo">
|
||||
<h2>Pendientes de nuestro lado</h2>
|
||||
<p>No son preguntas: son cosas a corregir en <code>admin.u-site.app</code>.</p>
|
||||
<ol>
|
||||
<li><strong>Tokens de 90 años</strong> (<code>config/token.go:26</code>). Un token
|
||||
filtrado es acceso permanente. Ponerle un vencimiento razonable — y recién ahí la
|
||||
caché y el reintento del cliente van a tener sentido.</li>
|
||||
<li><strong>La cookie se emite con <code>Secure: false</code></strong>
|
||||
(<code>config/token.go:44</code>), así que viaja también por HTTP plano.</li>
|
||||
<li><strong>Autenticación por cookie en una API máquina-a-máquina</strong> es el
|
||||
problema de fondo: obliga a manejar cookie jar y no permite allowlist por IP.
|
||||
<code>/api/v2</code> ya lo resuelve bien.</li>
|
||||
<li><strong>La doble codificación de dLocal</strong> no aporta nada; devolver el
|
||||
JSON anidado directo sería un cambio compatible si se versiona.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,217 @@
|
||||
# Contrato real de la API v1 de `admin.u-site.app`
|
||||
|
||||
Respuestas verificadas **leyendo el código del servidor**, no inferidas desde
|
||||
el cliente. Cada punto cita el archivo y la línea para que se pueda auditar.
|
||||
|
||||
Base de todos los endpoints: `https://admin.u-site.app/api/v1/…`
|
||||
(`rest/routes/routes.go:10` monta `/api`, `rest/routes/api.go:38` agrega `v1`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Formato de `expires_in` → **timestamp Unix absoluto**
|
||||
|
||||
Su interpretación es la correcta. En `config/token.go:22-49`:
|
||||
|
||||
```go
|
||||
t.Expire = ninetyYears // acá es duración en segundos
|
||||
expiresIn := time.Now().Add(time.Duration(t.Expire)*time.Second).Unix()
|
||||
claims["exp"] = expiresIn
|
||||
t.Expire = expiresIn // ← se SOBREESCRIBE con el absoluto
|
||||
```
|
||||
|
||||
El campo se reusa: entra como duración y sale como timestamp. Lo que viaja en
|
||||
la respuesta es `expiresIn`, o sea **segundos desde epoch**.
|
||||
|
||||
> **Pero hay algo más importante:** cuando `Login` llama a `CreateToken` no le
|
||||
> pasa vencimiento (`pkg/auth/user.go:98`), así que aplica el default de
|
||||
> `ninetyYears` — **90 años**. En la práctica el token de esa integración
|
||||
> nunca expira, y toda la lógica de caché y renovación no se ejerce jamás.
|
||||
> Esto hay que cambiarlo del lado nuestro (ver Pendientes).
|
||||
|
||||
## 2. Respuesta real de `POST /api/v1/oauth/token`
|
||||
|
||||
`rest/controllers/api/auth_controller.go:42-45`:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"expires_in": 4626547200
|
||||
}
|
||||
```
|
||||
|
||||
- El campo es **`token`**, no `access_token`.
|
||||
- `expires_in` está en el nivel raíz.
|
||||
- No hay `token_type`, ni `refresh_token`, ni envoltorio.
|
||||
|
||||
Errores (todos con HTTP 401), `auth_controller.go:15-40`:
|
||||
|
||||
```json
|
||||
{ "error": true, "message": "Invalid Credentials" }
|
||||
```
|
||||
|
||||
## 3. QR y VCF → **JSON plano**
|
||||
|
||||
| Endpoint | Método | Respuesta |
|
||||
|---|---|---|
|
||||
| `/api/v1/generate-qr-tmp` | POST | `{"url": "https://…"}` |
|
||||
| `/api/v1/generate-url-qr` | POST | `{"url": "https://…"}` |
|
||||
| `/api/v1/generate-vcf` | POST | `{"success": true, "url": "https://…"}` |
|
||||
| `/api/v1/generate-qr` | POST | **no es JSON**: devuelve la imagen binaria `Content-Type: image/webp` |
|
||||
|
||||
`vcard_qr.go:133-135`, `vcard_vcf.go:103-106`, `vcard_qr.go:169-170`.
|
||||
|
||||
Ojo con el último: `generate-qr` responde bytes de imagen, no JSON. Si el
|
||||
cliente hace `json_decode` de eso, falla siempre.
|
||||
|
||||
Errores: `{"error": "Datos inválidos"}` con 400, o `{"error": "<detalle>"}` con 500.
|
||||
|
||||
## 4. dLocal → **sí, el envoltorio raro es real**
|
||||
|
||||
Nadie lo adivinó mal. `rest/controllers/api/dlocal_controller.go:42-45`:
|
||||
|
||||
```go
|
||||
return c.Status(200).JSON(fiber.Map{
|
||||
"message": "Plan creado exitosamente.",
|
||||
"response": string(response), // ← el JSON de dLocal, como STRING
|
||||
})
|
||||
```
|
||||
|
||||
O sea, doble codificación real:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Plan creado exitosamente.",
|
||||
"response": "{\"id\":\"PLAN-123\",\"status\":\"active\"}"
|
||||
}
|
||||
```
|
||||
|
||||
Hay que hacer `json_decode` **dos veces**: una del cuerpo y otra del campo
|
||||
`response`. Aplica igual a los 7 endpoints dLocal (líneas 44, 77, 110, 137,
|
||||
162, 188, 221). El campo `message` cambia de texto según el endpoint, así que
|
||||
no conviene usarlo para decidir nada.
|
||||
|
||||
## 5. Cuerpo de error de sesión inválida → **hay DOS textos distintos**
|
||||
|
||||
Acá está el problema que sospechaban. `rest/middlewares/auth.go:271-286`:
|
||||
|
||||
```go
|
||||
token := c.Cookies("Verify-Rest-Token")
|
||||
if token == "" {
|
||||
return c.Status(401).JSON("Token not found") // ← falta la cookie
|
||||
}
|
||||
… ErrorHandler: return ctx.Status(401).JSON("Invalid Attempt") // ← cookie inválida/vencida
|
||||
```
|
||||
|
||||
- Sin cookie → cuerpo `"Token not found"`
|
||||
- Cookie presente pero inválida o vencida → cuerpo `"Invalid Attempt"`
|
||||
|
||||
Los dos son **JSON string, con comillas incluidas** — el cuerpo literal es
|
||||
`"Invalid Attempt"`, 16 bytes, no el texto pelado.
|
||||
|
||||
Su reintento solo cubre el segundo caso. **Recomendación: reintentar ante
|
||||
cualquier 401**, sin mirar el texto. Es más robusto y no depende de una
|
||||
redacción que puede cambiar.
|
||||
|
||||
## 6. URLs base → **una sola raíz, sin derivar por `str_replace`**
|
||||
|
||||
Todos cuelgan de `https://admin.u-site.app/api/v1/`. No hay hosts separados
|
||||
por grupo, así que lo correcto es guardar **la raíz** y concatenar la ruta,
|
||||
no guardar la URL del token y derivar las demás:
|
||||
|
||||
```
|
||||
POST /api/v1/oauth/token
|
||||
POST /api/v1/generate-qr-tmp
|
||||
POST /api/v1/generate-qr (devuelve imagen)
|
||||
POST /api/v1/generate-vcf
|
||||
POST /api/v1/generate-url-qr
|
||||
POST /api/v1/dlocal/subscription/crear-plan
|
||||
GET /api/v1/dlocal/subscription/ver-plan/:planID
|
||||
PATCH /api/v1/dlocal/subscription/actualizar-plan/:planID
|
||||
GET /api/v1/dlocal/subscription/plan/all
|
||||
PATCH /api/v1/dlocal/subscription/plan/:planId/subscription/:subscriptionId/deactivate
|
||||
GET /api/v1/dlocal/subscription/:subscriptionId/execution/:invoiceId
|
||||
POST /api/v1/dlocal/payment/crear-pago
|
||||
POST /api/v1/rapyd/wallet/create
|
||||
```
|
||||
|
||||
## 7. El header `Cookie` → **es OBLIGATORIO, y hoy está roto**
|
||||
|
||||
Esta es la más crítica de las 10.
|
||||
|
||||
La API **no acepta Bearer**. `AuthApi()` lee exclusivamente la cookie
|
||||
(`rest/middlewares/auth.go:272-281`):
|
||||
|
||||
```go
|
||||
token := c.Cookies("Verify-Rest-Token")
|
||||
if token == "" { return c.Status(401).JSON("Token not found") }
|
||||
TokenLookup: "cookie:Verify-Rest-Token"
|
||||
```
|
||||
|
||||
Consecuencias:
|
||||
|
||||
- Mandar el token en el body o como `Authorization: Bearer` **no autentica nada**.
|
||||
- Con el placeholder literal `...` sin completar, **los 12 endpoints
|
||||
autenticados devuelven 401 "Token not found"**. Si algo de eso "funciona"
|
||||
hoy, es porque el cliente HTTP tiene cookie jar y está reusando la cookie
|
||||
que `POST /oauth/token` deja seteada en la respuesta (`config/token.go:40-47`).
|
||||
- **`session_id` no hace falta**: el middleware no lo mira. Se puede quitar.
|
||||
|
||||
Lo correcto es tomar el `token` de la respuesta de `oauth/token` y mandarlo
|
||||
como `Cookie: Verify-Rest-Token=<token>`, o dejar que el cliente maneje
|
||||
cookies solo.
|
||||
|
||||
## 8. Control de acceso por IP → **no, para `/api/v1` no hay**
|
||||
|
||||
`/api/v1` solo valida la cookie JWT. No hay allowlist de IP; no hace falta
|
||||
registrar la IP del servidor de producción.
|
||||
|
||||
Sí existe validación por IP, pero en **otra** API: `/api/v2` usa `ApiKey` con
|
||||
IP obligatoria y scopes, y `/api/v1/pagos-externos` usa `AuthServicioPago`
|
||||
(ambas excluidas de `AuthApi` en `auth.go:266-268`). Si prefieren un esquema
|
||||
con IP fija y token que no vive en una cookie, **`/api/v2` es el camino** y
|
||||
vale la pena migrar ahí en vez de arreglar el actual.
|
||||
|
||||
## 9. Usuario y contraseña
|
||||
|
||||
No van en este documento. Son las credenciales de un usuario real de la tabla
|
||||
`users` (`login.CheckLogin()`, `auth_controller.go:28`). Que las entreguen por
|
||||
un canal seguro y, dado que llevan 15 meses sin rotar, **conviene crear un
|
||||
usuario dedicado a la integración** en vez de reusar uno de persona.
|
||||
|
||||
## 10. Webhooks → **sí existen, pero no para esto**
|
||||
|
||||
Hay webhooks entrantes de pago ya montados (`rest/routes/publicas.go:22-33`):
|
||||
`/webhooks/bold`, `/webhooks/dlocal`, `/webhooks/paypal`, más Coolify y
|
||||
Telegram.
|
||||
|
||||
Pero son para que **la pasarela nos avise a nosotros**, no para avisarle a un
|
||||
tercero. Hoy **no existe** un callback saliente hacia el sistema de ustedes ni
|
||||
para pagos ni para QR. Si lo necesitan, es desarrollo nuevo de nuestro lado.
|
||||
|
||||
Mientras tanto, la alternativa ya construida es `/api/v1/pagos-externos`
|
||||
(`api.go:32-34`), pensada justo para apps de terceros:
|
||||
|
||||
```
|
||||
GET /api/v1/pagos-externos/pasarelas
|
||||
POST /api/v1/pagos-externos/solicitar
|
||||
GET /api/v1/pagos-externos/:referencia/estado
|
||||
```
|
||||
|
||||
Usa token Bearer + IP (no cookie), así que evita todo el problema del punto 7.
|
||||
**Para una integración nueva, recomendamos esta y no los endpoints dLocal
|
||||
directos.**
|
||||
|
||||
---
|
||||
|
||||
## Pendientes de nuestro lado (no son preguntas, son cosas a corregir)
|
||||
|
||||
1. **Tokens de 90 años** (`config/token.go:26`). Un token filtrado es acceso
|
||||
permanente. Hay que ponerle un vencimiento razonable — y recién ahí la
|
||||
caché y el reintento del cliente van a tener sentido.
|
||||
2. **La cookie se emite con `Secure: false`** (`config/token.go:44`), así que
|
||||
viaja también por HTTP plano.
|
||||
3. **Autenticación por cookie en una API máquina-a-máquina** es el problema de
|
||||
fondo: obliga a manejar cookie jar y no permite IP allowlist. `/api/v2` ya
|
||||
resuelve esto bien.
|
||||
4. **La doble codificación de dLocal** (punto 4) no aporta nada; devolver el
|
||||
JSON anidado directo sería un cambio compatible si se versiona.
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/sujit-baniya/fiber-boilerplate
|
||||
|
||||
go 1.25.0
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
@@ -25,12 +25,11 @@ require (
|
||||
github.com/pyroscope-io/pyroscope v0.37.2
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
|
||||
github.com/sujit-baniya/flash v0.1.9
|
||||
github.com/sujit-baniya/ip v0.0.10
|
||||
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
@@ -43,7 +42,11 @@ require (
|
||||
require (
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
|
||||
github.com/chai2010/webp v1.4.0
|
||||
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe
|
||||
github.com/chromedp/chromedp v0.16.0
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
github.com/microsoft/go-mssqldb v1.7.2
|
||||
@@ -53,12 +56,16 @@ require (
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/valyala/fasthttp v1.56.0
|
||||
go.mongodb.org/mongo-driver v1.17.9
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/oauth2 v0.23.0
|
||||
gorm.io/driver/sqlite v1.5.6
|
||||
gorm.io/driver/sqlserver v1.5.3
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
@@ -66,19 +73,26 @@ require (
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
|
||||
github.com/casbin/govaluate v1.2.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-test/deep v1.1.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
@@ -88,7 +102,6 @@ require (
|
||||
github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gookit/filter v1.2.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
@@ -116,7 +129,6 @@ require (
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pyroscope-io/dotnetdiag v1.2.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
@@ -126,7 +138,6 @@ require (
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/tklauser/numcpus v0.8.0 // indirect
|
||||
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect
|
||||
github.com/valyala/fasthttp v1.56.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
@@ -138,8 +149,6 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/oauth2 v0.23.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/term v0.43.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
|
||||
@@ -33,6 +33,8 @@ cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvf
|
||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
|
||||
@@ -95,8 +97,6 @@ github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6Ct
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
|
||||
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
@@ -137,6 +137,12 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
|
||||
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
|
||||
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe h1:PmhRwLZ8qLtldQCBiydwdPFJI8WVQ936ux1cpgHLRb8=
|
||||
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
|
||||
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
|
||||
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
@@ -169,6 +175,12 @@ github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5O
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU=
|
||||
github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
@@ -206,6 +218,8 @@ github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7b
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
@@ -236,9 +250,14 @@ github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncV
|
||||
github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
|
||||
github.com/gobuffalo/here v0.6.7 h1:hpfhh+kt2y9JLDfhYUxxCRxQol540jsVfKUZzjlbp8o=
|
||||
github.com/gobuffalo/here v0.6.7/go.mod h1:vuCfanjqckTuRlqAitJz6QC4ABNnS27wLb816UhsPcc=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofiber/fiber/v2 v2.1.0/go.mod h1:aG+lMkwy3LyVit4CnmYUbUdgjpc3UYOltvlJZ78rgQ0=
|
||||
github.com/gofiber/fiber/v2 v2.9.0/go.mod h1:Ah3IJikrKNRepl/HuVawppS25X7FWohwfCSRn7kJG28=
|
||||
github.com/gofiber/fiber/v2 v2.44.0/go.mod h1:VTMtb/au8g01iqvHyaCzftuM/xmZgKOZCtFzz6CdV9w=
|
||||
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
|
||||
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
@@ -439,9 +458,6 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.11.8/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||
github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
@@ -466,6 +482,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
@@ -556,9 +574,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
|
||||
github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk=
|
||||
github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0=
|
||||
github.com/oschwald/maxminddb-golang v1.8.0/go.mod h1:RXZtst0N6+FY/3qCNmZMBApR19cdQj43/NM9VkrNAis=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
@@ -669,8 +686,6 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/sujit-baniya/flash v0.1.9 h1:9+g9GrauXF8JH7ivoG4XBjl04r+vNkB9ZFN+5RKTugo=
|
||||
github.com/sujit-baniya/flash v0.1.9/go.mod h1:nNyzxxxU11ctsPe99BhV39kP6/jcfoRDp4T7P5m4K4E=
|
||||
github.com/sujit-baniya/ip v0.0.10 h1:4FxdNgibzkDGS6B1+2//o4nStxmvD11i3NEgljh6SLk=
|
||||
github.com/sujit-baniya/ip v0.0.10/go.mod h1:9wktAv0khHVF2t6lIWRbNV4ScqcHSi92pc5RfBb9YV8=
|
||||
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw=
|
||||
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
|
||||
@@ -687,8 +702,6 @@ github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqri
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA=
|
||||
github.com/valyala/fasthttp v1.23.0/go.mod h1:0mw2RjXGOzxf4NL2jni3gUQ7LfjjUSiG5sskOUUSEpU=
|
||||
github.com/valyala/fasthttp v1.24.0/go.mod h1:0mw2RjXGOzxf4NL2jni3gUQ7LfjjUSiG5sskOUUSEpU=
|
||||
github.com/valyala/fasthttp v1.45.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
|
||||
github.com/valyala/fasthttp v1.56.0 h1:bEZdJev/6LCBlpdORfrLu/WOZXXxvrUQSiyniuaoW8U=
|
||||
github.com/valyala/fasthttp v1.56.0/go.mod h1:sReBt3XZVnudxuLOx4J/fMrJVorWRiWY2koQKgABiVI=
|
||||
@@ -753,7 +766,6 @@ golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
@@ -847,7 +859,6 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226101413-39120d07d75e/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
@@ -924,7 +935,6 @@ golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -952,7 +962,6 @@ golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -987,9 +996,8 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
||||
|
||||
@@ -3,11 +3,15 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/pyroscope-io/pyroscope/pkg/agent/profiler"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/config"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/migrations"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/auth"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/rest/routes"
|
||||
@@ -33,6 +37,13 @@ func main() {
|
||||
// Actualizar versión del servidor
|
||||
app.Http.Server.Version = app.Version
|
||||
|
||||
// Los archivos de /uploads dejan de ser públicos: solo se sirven a usuarios
|
||||
// con sesión iniciada en el panel. Los clientes del portal siguen bajando sus
|
||||
// documentos por los endpoints dedicados, que validan que les pertenezcan.
|
||||
config.UploadsGuard = func(c *fiber.Ctx) bool {
|
||||
return auth.IsLoggedIn(c)
|
||||
}
|
||||
|
||||
// Habilitar CORS
|
||||
app.Http.Server.App.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
@@ -50,8 +61,13 @@ func main() {
|
||||
migrations.MigratePortal()
|
||||
// Crear tabla de Landing Generator
|
||||
migrations.MigrateLanding()
|
||||
// Crear tablas de integraciones y pasarelas si no existen
|
||||
app.Http.Database.DB.AutoMigrate(
|
||||
// Crear tablas de integraciones y pasarelas si no existen.
|
||||
// AutoMigrate(a, b, c...) se detiene en el primer modelo que falle: todo lo
|
||||
// que viene después en la lista se queda sin tabla, en silencio (fue la
|
||||
// causa real de que plantillas_documento/tarifas desaparecieran de
|
||||
// producción — un modelo antes en la lista falló y todo lo posterior no se
|
||||
// llegó a crear). Migrando uno por uno, un modelo roto no bloquea al resto.
|
||||
modelosBase := []interface{}{
|
||||
// Tablas base del sistema (columnas nuevas)
|
||||
&models.Users{},
|
||||
&models.Roles{},
|
||||
@@ -86,8 +102,13 @@ func main() {
|
||||
&models.PortalPasswordResetToken{},
|
||||
// Integración VCard API Admin
|
||||
&models.VcardApiConfig{},
|
||||
// Integración Coolify
|
||||
// Integración Coolify (multi-instancia)
|
||||
&models.CoolifyConfig{},
|
||||
// AI Config: campos del agente Telegram
|
||||
&models.AiConfig{},
|
||||
// Agente Telegram: historial y chats autorizados
|
||||
&models.TelegramAgentHistory{},
|
||||
&models.TelegramAgentAuth{},
|
||||
// Servidores: nuevos campos de agente + tabla join de integraciones
|
||||
&models.Servidor{},
|
||||
&models.ConxDb{},
|
||||
@@ -113,7 +134,41 @@ func main() {
|
||||
// Tablero de tareas
|
||||
&models.Tarea{},
|
||||
&models.TareaComentario{},
|
||||
)
|
||||
// Pagos externos: API de cobros para aplicaciones de terceros
|
||||
&models.ServicioPagoExterno{},
|
||||
&models.SolicitudPagoExterna{},
|
||||
// Soporte: webhook de correo entrante
|
||||
&models.SoporteWebhookConfig{},
|
||||
// Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA
|
||||
&models.PlantillaDocumento{},
|
||||
&models.Tarifa{},
|
||||
&models.DocumentoGenerado{},
|
||||
&models.Arquitectura{},
|
||||
// Vinculación de Telegram para staff interno
|
||||
&models.TelegramStaffToken{},
|
||||
// uMind: chat con IA embebible por tenant (widget web + RAG)
|
||||
&models.UmindTenant{},
|
||||
&models.UmindAgente{},
|
||||
&models.UmindDocumento{},
|
||||
&models.UmindChunk{},
|
||||
&models.UmindMensaje{},
|
||||
&models.UmindHerramienta{},
|
||||
&models.UmindCanal{},
|
||||
&models.UmindConexion{},
|
||||
&models.UmindEventoLog{},
|
||||
&models.UmindPlan{},
|
||||
&models.UmindUso{},
|
||||
// API Keys de /api/v2 (token + IP obligatoria + scopes)
|
||||
&models.ApiKey{},
|
||||
// Integraciones: OCR y transcripción de audio (servicios propios)
|
||||
&models.OcrConfig{},
|
||||
&models.WhisperAsrConfig{},
|
||||
}
|
||||
for _, m := range modelosBase {
|
||||
if err := app.Http.Database.DB.AutoMigrate(m); err != nil {
|
||||
log.Printf("[MIGRATE] Error migrando %T: %v", m, err)
|
||||
}
|
||||
}
|
||||
// Seed automático (idempotente) de módulos del sistema
|
||||
migrations.SeedRenovaciones()
|
||||
migrations.SeedIntegraciones()
|
||||
@@ -128,11 +183,34 @@ func main() {
|
||||
migrations.SeedShield()
|
||||
migrations.SeedPartnerRecursos()
|
||||
models.SeedContabilidad()
|
||||
models.SeedBalanceData()
|
||||
// SeedBalanceData carga transacciones contables reales de 2026. Ya no se
|
||||
// ejecuta en cada arranque: si el contador editaba o borraba una de esas
|
||||
// filas, el siguiente reinicio la recreaba y el balance quedaba duplicado.
|
||||
// Para recargarla puntualmente: SEED_BALANCE=1 ./apiv2
|
||||
if os.Getenv("SEED_BALANCE") == "1" {
|
||||
log.Println("[SEED] SEED_BALANCE=1 — cargando datos de balance")
|
||||
models.SeedBalanceData()
|
||||
}
|
||||
migrations.SeedContabilidadMenu()
|
||||
migrations.SeedWebSms()
|
||||
migrations.SeedUrlMonitor()
|
||||
migrations.SeedTareas()
|
||||
migrations.SeedPagosExternos()
|
||||
migrations.SeedAutomatizacionIA()
|
||||
migrations.SeedUmind()
|
||||
migrations.SeedDocumentacion()
|
||||
// Va antes de MigrarUmindAgentes: libera las columnas huérfanas que
|
||||
// dejó el refactor multi-agente. Sin esto no se puede insertar nada en
|
||||
// uMind (ver el comentario de la función).
|
||||
migrations.LiberarColumnasHuerfanasUmind()
|
||||
migrations.IndicesUnicosMessageID()
|
||||
migrations.MigrarUmindAgentes()
|
||||
migrations.SeedApiKeys()
|
||||
if n, err := models.RepararEstadosTareaInvalidos(); err != nil {
|
||||
log.Printf("[FIX] Error reparando estados de tareas: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[FIX] %d tarea(s) con estado inválido reparadas (ahora visibles en el Kanban)", n)
|
||||
}
|
||||
// Cargar config SMTP desde BD (sobreescribe valores del .env/config.yml si hay registro activo)
|
||||
if smtpCfg, err := models.GetSmtpConfig(); err == nil {
|
||||
app.Http.Mail.Host = smtpCfg.Host
|
||||
@@ -152,6 +230,20 @@ func main() {
|
||||
defer services.DetenerCron()
|
||||
// Cargar rutas
|
||||
routes.LoadRoutes(app.Http.Server.App)
|
||||
|
||||
// Con las rutas ya montadas se puede avisar de los ítems del menú que
|
||||
// apuntan a una URL inexistente — el síntoma es un 404 que parece un
|
||||
// problema de permisos.
|
||||
rutasGET := map[string]bool{}
|
||||
for _, capa := range app.Http.Server.Stack() {
|
||||
for _, r := range capa {
|
||||
if r.Method == "GET" {
|
||||
rutasGET[r.Path] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
migrations.VerificarUrlsDeSubmodulos(rutasGET)
|
||||
|
||||
app.Http.Route404()
|
||||
log.Fatal(app.Http.Server.ServeWithGraceFullShutdown())
|
||||
}
|
||||
|
||||
+635
-82
@@ -3,6 +3,7 @@ package migrations
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
@@ -25,8 +26,11 @@ type Session struct {
|
||||
func Migrate() {
|
||||
log.Println("Initiating migration...")
|
||||
|
||||
// Migrate the main application models
|
||||
if err := app.Http.Database.DB.Migrator().AutoMigrate(
|
||||
// Migrate the main application models.
|
||||
// Un solo AutoMigrate(a, b, c...) se detiene en el primer modelo que falle:
|
||||
// todo lo que viene después en la lista se queda sin tabla. Migrando uno por
|
||||
// uno, un modelo roto no bloquea al resto (mismo fix que en main.go).
|
||||
modelosPrincipales := []interface{}{
|
||||
&models.Roles{},
|
||||
&models.Users{},
|
||||
&models.Modules{},
|
||||
@@ -104,8 +108,38 @@ func Migrate() {
|
||||
// Tablero de tareas
|
||||
&models.Tarea{},
|
||||
&models.TareaComentario{},
|
||||
); err != nil {
|
||||
log.Fatalf("Error during main migration: %v", err)
|
||||
// Pagos externos: API de cobros para aplicaciones de terceros
|
||||
&models.ServicioPagoExterno{},
|
||||
&models.SolicitudPagoExterna{},
|
||||
// Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA
|
||||
&models.PlantillaDocumento{},
|
||||
&models.Tarifa{},
|
||||
&models.DocumentoGenerado{},
|
||||
&models.Arquitectura{},
|
||||
// Vinculación de Telegram para staff interno
|
||||
&models.TelegramStaffToken{},
|
||||
// uMind: chat con IA embebible por tenant (widget web + RAG)
|
||||
&models.UmindTenant{},
|
||||
&models.UmindAgente{},
|
||||
&models.UmindDocumento{},
|
||||
&models.UmindChunk{},
|
||||
&models.UmindMensaje{},
|
||||
&models.UmindHerramienta{},
|
||||
&models.UmindCanal{},
|
||||
&models.UmindConexion{},
|
||||
&models.UmindEventoLog{},
|
||||
&models.UmindPlan{},
|
||||
&models.UmindUso{},
|
||||
// API Keys de /api/v2 (token + IP obligatoria + scopes)
|
||||
&models.ApiKey{},
|
||||
// Integraciones: OCR y transcripción de audio (servicios propios)
|
||||
&models.OcrConfig{},
|
||||
&models.WhisperAsrConfig{},
|
||||
}
|
||||
for _, m := range modelosPrincipales {
|
||||
if err := app.Http.Database.DB.Migrator().AutoMigrate(m); err != nil {
|
||||
log.Printf("[MIGRATE] Error migrando %T: %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Open (or create) a SQLite database for sessions
|
||||
@@ -150,9 +184,49 @@ func Migrate() {
|
||||
// Agregar submódulo "Partner Recursos" al módulo Portal de Clientes
|
||||
SeedPartnerRecursos()
|
||||
|
||||
// Crear módulo y submódulos de Automatización IA (cotizaciones, contratos, asistente)
|
||||
SeedAutomatizacionIA()
|
||||
|
||||
log.Println("Migration Completed...")
|
||||
}
|
||||
|
||||
// asignarSubmodulosSiFaltan agrega submódulos a un rol solo si el rol todavía no
|
||||
// tiene NINGUNO de ellos.
|
||||
//
|
||||
// Antes los seeds hacían Append incondicional en cada arranque: si un admin le
|
||||
// quitaba un módulo a un rol desde el panel, el siguiente despliegue se lo
|
||||
// devolvía y la gestión de permisos era, en la práctica, imposible de sostener.
|
||||
func asignarSubmodulosSiFaltan(rol *models.Roles, subs []models.Submodules) {
|
||||
if rol == nil || len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
db := app.Http.Database.DB
|
||||
|
||||
var ids []uint
|
||||
for _, s := range subs {
|
||||
if s.ID > 0 {
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var yaAsignados int64
|
||||
db.Table("roles_submodules").
|
||||
Where("roles_id = ? AND submodules_id IN ?", rol.ID, ids).
|
||||
Count(&yaAsignados)
|
||||
if yaAsignados > 0 {
|
||||
// El rol ya conoce este grupo de submódulos: lo que tenga hoy es una
|
||||
// decisión del administrador y no se toca.
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Model(rol).Association("Submodules").Append(&subs); err != nil {
|
||||
log.Printf("[SEED] Error asignando submódulos al rol %s: %v", rol.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// SeedStatuspage agrega el submódulo "Statuspage" (Atlassian) al módulo "Integraciones"
|
||||
// y lo asigna a todos los roles. Es idempotente.
|
||||
func SeedStatuspage() {
|
||||
@@ -166,9 +240,15 @@ func SeedStatuspage() {
|
||||
}
|
||||
|
||||
entries := []struct{ title, desc, url string }{
|
||||
{"Statuspage", "Monitoreo de estado de servicios vía Atlassian Statuspage", "/app/statuspage"},
|
||||
// La página real es la pública /status (rest/routes/publicas.go):
|
||||
// /app/statuspage nunca existió y el ítem daba 404 al entrar.
|
||||
{"Statuspage", "Monitoreo de estado de servicios vía Atlassian Statuspage", "/status"},
|
||||
}
|
||||
|
||||
// La URL vieja apuntaba a /app/statuspage, que no existe: se corrige el
|
||||
// registro ya creado en vez de dejar un ítem de menú que da 404.
|
||||
db.Model(&models.Submodules{}).Where("url = ?", "/app/statuspage").Update("url", "/status")
|
||||
|
||||
var insertados []models.Submodules
|
||||
for _, e := range entries {
|
||||
var sub models.Submodules
|
||||
@@ -191,18 +271,15 @@ func SeedStatuspage() {
|
||||
insertados = append(insertados, sub)
|
||||
}
|
||||
|
||||
var roles []models.Roles
|
||||
if err := db.Find(&roles).Error; err != nil {
|
||||
log.Printf("[SEED] Error obteniendo roles: %v", err)
|
||||
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
|
||||
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
|
||||
var rolAdmin models.Roles
|
||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
|
||||
return
|
||||
}
|
||||
for _, rol := range roles {
|
||||
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
|
||||
log.Printf("[SEED] Error asignando submódulo Statuspage al rol '%s': %v", rol.Name, err)
|
||||
} else {
|
||||
log.Printf("[SEED] Submódulo Statuspage asignado al rol '%s'", rol.Name)
|
||||
}
|
||||
}
|
||||
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||
|
||||
log.Println("[SEED] Seed de Statuspage completado.")
|
||||
}
|
||||
@@ -397,13 +474,87 @@ func SeedRenovaciones() {
|
||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||
} else {
|
||||
db.Model(&rolAdmin).Association("Submodules").Append(&insertados)
|
||||
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||
log.Printf("[SEED] Submódulos de Renovaciones asignados al rol 'Administrador'")
|
||||
}
|
||||
|
||||
log.Println("[SEED] Seed de Renovaciones completado.")
|
||||
}
|
||||
|
||||
// SeedAutomatizacionIA inserta el módulo "Automatización IA" y sus submódulos
|
||||
// (Asistente, Plantillas de Documento, Tarifas) si no existen, y los asigna al
|
||||
// rol Administrador. Es idempotente.
|
||||
func SeedAutomatizacionIA() {
|
||||
db := app.Http.Database.DB
|
||||
|
||||
// 1. Crear/obtener módulo
|
||||
var modulo models.Modules
|
||||
result := db.Where("title = ?", "Automatización IA").First(&modulo)
|
||||
if result.Error != nil {
|
||||
modulo = models.Modules{
|
||||
Title: "Automatización IA",
|
||||
Description: "Cotizaciones, contratos, actas y cuentas de cobro generados por IA",
|
||||
ModifiedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&modulo).Error; err != nil {
|
||||
log.Printf("[SEED] Error creando módulo Automatización IA: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[SEED] Módulo 'Automatización IA' creado con ID %d", modulo.ID)
|
||||
} else {
|
||||
log.Printf("[SEED] Módulo 'Automatización IA' ya existe (ID %d)", modulo.ID)
|
||||
}
|
||||
|
||||
// 2. Definir submódulos
|
||||
entries := []struct{ title, desc, url string }{
|
||||
{"Asistente", "Chat propio: mismo motor y tools que el bot de Telegram", "/app/asistente"},
|
||||
{"Chats autorizados", "Quién puede hablarle al bot de Telegram y ejecutar acciones", "/app/agente/chats-autorizados"},
|
||||
{"Plantillas de Documento", "Fuente de verdad de cotización, contrato, acta y cuenta de cobro", "/app/automatizacion/plantillas"},
|
||||
{"Tarifas", "Valor por hora, licencias, VMs y márgenes usados al cotizar", "/app/automatizacion/tarifas"},
|
||||
}
|
||||
|
||||
var insertados []models.Submodules
|
||||
for _, e := range entries {
|
||||
var sub models.Submodules
|
||||
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
|
||||
sub = models.Submodules{
|
||||
Title: e.title,
|
||||
Description: e.desc,
|
||||
Url: e.url,
|
||||
ModuleId: modulo.ID,
|
||||
ModifiedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&sub).Error; err != nil {
|
||||
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
|
||||
} else {
|
||||
if sub.ModuleId != modulo.ID {
|
||||
if err := db.Model(&sub).Update("module_id", modulo.ID).Error; err != nil {
|
||||
log.Printf("[SEED] Error actualizando module_id de '%s': %v", sub.Title, err)
|
||||
} else {
|
||||
log.Printf("[SEED] Submódulo '%s' reasignado a módulo Automatización IA", sub.Title)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
|
||||
}
|
||||
}
|
||||
insertados = append(insertados, sub)
|
||||
}
|
||||
|
||||
// 3. Asignar solo al rol Administrador
|
||||
var rolAdmin models.Roles
|
||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||
} else {
|
||||
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||
log.Printf("[SEED] Submódulos de Automatización IA asignados al rol 'Administrador'")
|
||||
}
|
||||
|
||||
log.Println("[SEED] Seed de Automatización IA completado.")
|
||||
}
|
||||
|
||||
// SeedIntegraciones crea el módulo "Integraciones" con los submódulos
|
||||
// de Hostinger y Cloudflare, y los asigna a todos los roles existentes.
|
||||
// Es idempotente: si el registro ya existe (por URL) lo reutiliza sin duplicar.
|
||||
@@ -432,6 +583,8 @@ func SeedIntegraciones() {
|
||||
{"Hostinger", "Panel de VPS, dominios, hosting y DNS de Hostinger", "/app/hostinger"},
|
||||
{"Cloudflare", "Gestión de zonas, DNS, SSL y firewall en Cloudflare", "/app/cloudflare"},
|
||||
{"VCard API", "Integración con el sistema VCard externo (Laravel + Sanctum): usuarios, membresías, vcards, pagos y más", "/app/vcard-api"},
|
||||
{"OCR", "Extracción de texto de imágenes (comprobantes, capturas) vía servicio OCR propio", "/app/ocr"},
|
||||
{"Whisper ASR", "Transcripción de audio vía servicio propio de reconocimiento de voz (self-hosted)", "/app/whisper-asr"},
|
||||
}
|
||||
|
||||
var insertados []models.Submodules
|
||||
@@ -465,18 +618,15 @@ func SeedIntegraciones() {
|
||||
}
|
||||
|
||||
// 3. Asignar a todos los roles existentes
|
||||
var roles []models.Roles
|
||||
if err := db.Find(&roles).Error; err != nil {
|
||||
log.Printf("[SEED] Error obteniendo roles: %v", err)
|
||||
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
|
||||
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
|
||||
var rolAdmin models.Roles
|
||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
|
||||
return
|
||||
}
|
||||
for _, rol := range roles {
|
||||
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
|
||||
log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err)
|
||||
} else {
|
||||
log.Printf("[SEED] Submódulos de Integraciones asignados al rol '%s'", rol.Name)
|
||||
}
|
||||
}
|
||||
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||
|
||||
log.Println("[SEED] Seed de Integraciones completado.")
|
||||
}
|
||||
@@ -519,18 +669,15 @@ func SeedPasarelas() {
|
||||
insertados = append(insertados, sub)
|
||||
}
|
||||
|
||||
var roles []models.Roles
|
||||
if err := db.Find(&roles).Error; err != nil {
|
||||
log.Printf("[SEED] Error obteniendo roles: %v", err)
|
||||
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
|
||||
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
|
||||
var rolAdmin models.Roles
|
||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
|
||||
return
|
||||
}
|
||||
for _, rol := range roles {
|
||||
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
|
||||
log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err)
|
||||
} else {
|
||||
log.Printf("[SEED] Submódulo Pasarelas asignado al rol '%s'", rol.Name)
|
||||
}
|
||||
}
|
||||
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||
|
||||
log.Println("[SEED] Seed de Pasarelas completado.")
|
||||
}
|
||||
@@ -596,18 +743,15 @@ func SeedServidores() {
|
||||
}
|
||||
|
||||
// 3. Asignar a todos los roles existentes
|
||||
var roles []models.Roles
|
||||
if err := db.Find(&roles).Error; err != nil {
|
||||
log.Printf("[SEED] Error obteniendo roles: %v", err)
|
||||
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
|
||||
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
|
||||
var rolAdmin models.Roles
|
||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
|
||||
return
|
||||
}
|
||||
for _, rol := range roles {
|
||||
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
|
||||
log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err)
|
||||
} else {
|
||||
log.Printf("[SEED] Submódulos de Servidores asignados al rol '%s'", rol.Name)
|
||||
}
|
||||
}
|
||||
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||
|
||||
log.Println("[SEED] Seed de Servidores completado.")
|
||||
}
|
||||
@@ -667,18 +811,15 @@ func SeedAdministracion() {
|
||||
insertados = append(insertados, sub)
|
||||
}
|
||||
|
||||
var roles []models.Roles
|
||||
if err := db.Find(&roles).Error; err != nil {
|
||||
log.Printf("[SEED] Error obteniendo roles: %v", err)
|
||||
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
|
||||
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
|
||||
var rolAdmin models.Roles
|
||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
|
||||
return
|
||||
}
|
||||
for _, rol := range roles {
|
||||
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
|
||||
log.Printf("[SEED] Error asignando submódulos al rol '%s': %v", rol.Name, err)
|
||||
} else {
|
||||
log.Printf("[SEED] Submódulos de Administración asignados al rol '%s'", rol.Name)
|
||||
}
|
||||
}
|
||||
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||
|
||||
log.Println("[SEED] Seed de Administración completado.")
|
||||
}
|
||||
@@ -722,18 +863,15 @@ func SeedSaas() {
|
||||
insertados = append(insertados, sub)
|
||||
}
|
||||
|
||||
var roles []models.Roles
|
||||
if err := db.Find(&roles).Error; err != nil {
|
||||
log.Printf("[SEED] Error obteniendo roles: %v", err)
|
||||
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
|
||||
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
|
||||
var rolAdmin models.Roles
|
||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
|
||||
return
|
||||
}
|
||||
for _, rol := range roles {
|
||||
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
|
||||
log.Printf("[SEED] Error asignando submódulos SaaS al rol '%s': %v", rol.Name, err)
|
||||
} else {
|
||||
log.Printf("[SEED] Submódulos SaaS asignados al rol '%s'", rol.Name)
|
||||
}
|
||||
}
|
||||
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||
|
||||
log.Println("[SEED] Seed de SaaS completado.")
|
||||
}
|
||||
@@ -799,13 +937,15 @@ func SeedTelegram() {
|
||||
log.Printf("[SEED] Submódulo 'Telegram' ya existe (ID %d)", sub.ID)
|
||||
}
|
||||
|
||||
var roles []models.Roles
|
||||
if err := db.Find(&roles).Error; err != nil {
|
||||
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
|
||||
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
|
||||
var rolAdmin models.Roles
|
||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
|
||||
return
|
||||
}
|
||||
for _, rol := range roles {
|
||||
db.Model(&rol).Association("Submodules").Append(&sub)
|
||||
}
|
||||
asignarSubmodulosSiFaltan(&rolAdmin, []models.Submodules{sub})
|
||||
log.Println("[SEED] Seed de Telegram completado.")
|
||||
}
|
||||
|
||||
@@ -857,13 +997,15 @@ func SeedPortalClientes() {
|
||||
insertados = append(insertados, sub)
|
||||
}
|
||||
|
||||
var roles []models.Roles
|
||||
if err := db.Find(&roles).Error; err != nil {
|
||||
// Solo se asigna al rol Administrador: los demás roles pueden estar
|
||||
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
|
||||
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
|
||||
var rolAdmin models.Roles
|
||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
|
||||
return
|
||||
}
|
||||
for _, rol := range roles {
|
||||
db.Model(&rol).Association("Submodules").Append(&insertados)
|
||||
}
|
||||
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||
log.Println("[SEED] Seed de Portal de Clientes completado.")
|
||||
}
|
||||
|
||||
@@ -882,7 +1024,7 @@ func SeedNotifDefaults() {
|
||||
{Evento: "servidor_vence_pronto", Destinatario: "admin", CanalEmail: true, CanalTelegram: true, CanalSistema: true, Descripcion: "Admin recibe cuando un VPS está próximo a vencer"},
|
||||
{Evento: "servidor_recurso_alto", Destinatario: "admin", CanalEmail: false, CanalTelegram: true, CanalSistema: true, Descripcion: "Admin recibe cuando CPU/RAM/Disco supera el umbral"},
|
||||
// Tareas
|
||||
{Evento: "tarea_asignada", Destinatario: "admin", CanalEmail: true, CanalTelegram: false, CanalSistema: true, Descripcion: "Notifica cuando se asigna una tarea a un usuario"},
|
||||
{Evento: "tarea_asignada", Destinatario: "admin", CanalEmail: true, CanalTelegram: true, CanalSistema: true, Descripcion: "Notifica cuando se asigna una tarea a un usuario"},
|
||||
{Evento: "tarea_estado", Destinatario: "admin", CanalEmail: false, CanalTelegram: false, CanalSistema: true, Descripcion: "Notifica cuando cambia el estado de una tarea"},
|
||||
{Evento: "tarea_comentario", Destinatario: "admin", CanalEmail: false, CanalTelegram: false, CanalSistema: true, Descripcion: "Notifica cuando hay un nuevo comentario en una tarea"},
|
||||
}
|
||||
@@ -895,6 +1037,17 @@ func SeedNotifDefaults() {
|
||||
}
|
||||
}
|
||||
log.Printf("[SEED] SeedNotifDefaults: %d nuevas configuraciones creadas.", created)
|
||||
|
||||
// Corrección puntual (una sola vez): tarea_asignada/admin se sembró originalmente
|
||||
// con canal_telegram=false, dejando la notificación muerta desde su creación.
|
||||
// Si nadie tocó el registro desde entonces, se activa por defecto.
|
||||
var taRow models.NotifEventoConfig
|
||||
if err := db.Where("evento = ? AND destinatario = ?", "tarea_asignada", "admin").First(&taRow).Error; err == nil {
|
||||
if !taRow.CanalTelegram && taRow.UpdatedAt.Equal(taRow.CreatedAt) {
|
||||
db.Model(&taRow).Update("canal_telegram", true)
|
||||
log.Println("[SEED] tarea_asignada/admin: canal_telegram activado (corrección de default)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SeedShield es un stub idempotente para la configuración de Shield.
|
||||
@@ -935,7 +1088,7 @@ func SeedPartnerRecursos() {
|
||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||
} else {
|
||||
db.Model(&rolAdmin).Association("Submodules").Append(&sub)
|
||||
asignarSubmodulosSiFaltan(&rolAdmin, []models.Submodules{sub})
|
||||
log.Printf("[SEED] Submódulo 'Partner Recursos' asignado al rol 'Administrador'")
|
||||
}
|
||||
log.Println("[SEED] Seed de Partner Recursos completado.")
|
||||
@@ -989,7 +1142,7 @@ func SeedContabilidadMenu() {
|
||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||
} else {
|
||||
db.Model(&rolAdmin).Association("Submodules").Append(&insertados)
|
||||
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
|
||||
log.Printf("[SEED] Submódulos de Contabilidad asignados al rol 'Administrador'")
|
||||
}
|
||||
log.Println("[SEED] Seed de Contabilidad completado.")
|
||||
@@ -1026,7 +1179,7 @@ func SeedWebSms() {
|
||||
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||
} else {
|
||||
db.Model(&rolAdmin).Association("Submodules").Append(&[]models.Submodules{sub})
|
||||
asignarSubmodulosSiFaltan(&rolAdmin, []models.Submodules{sub})
|
||||
log.Printf("[SEED] Submódulo 'WebSMS' asignado al rol 'Administrador'")
|
||||
}
|
||||
log.Println("[SEED] Seed de WebSMS completado.")
|
||||
@@ -1062,7 +1215,7 @@ func SeedUrlMonitor() {
|
||||
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||
} else {
|
||||
db.Model(&rol).Association("Submodules").Append(&[]models.Submodules{sub})
|
||||
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
|
||||
log.Printf("[SEED] Submódulo 'Monitor de URLs' asignado al rol 'Administrador'")
|
||||
}
|
||||
log.Println("[SEED] Seed de Monitor de URLs completado.")
|
||||
@@ -1098,8 +1251,408 @@ func SeedTareas() {
|
||||
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||
} else {
|
||||
db.Model(&rol).Association("Submodules").Append(&[]models.Submodules{sub})
|
||||
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
|
||||
log.Printf("[SEED] Submódulo 'Tareas' asignado al rol 'Administrador'")
|
||||
}
|
||||
log.Println("[SEED] Seed de Tareas completado.")
|
||||
}
|
||||
|
||||
// SeedPagosExternos registra el submódulo del panel de administración de
|
||||
// servicios de pago externos (emisión de tokens de API para terceros).
|
||||
func SeedPagosExternos() {
|
||||
db := app.Http.Database.DB
|
||||
var modulo models.Modules
|
||||
if err := db.Where("title = ?", "Administración").First(&modulo).Error; err != nil {
|
||||
log.Println("[SEED] Módulo 'Administración' no encontrado, se omite SeedPagosExternos")
|
||||
return
|
||||
}
|
||||
url := "/app/pagos-externos"
|
||||
var sub models.Submodules
|
||||
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
|
||||
sub = models.Submodules{
|
||||
Title: "Pagos externos",
|
||||
Description: "Tokens de API para que apps de terceros pidan cobros por Bold/dLocal/PayPal",
|
||||
Url: url,
|
||||
ModuleId: modulo.ID,
|
||||
ModifiedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&sub).Error; err != nil {
|
||||
log.Printf("[SEED] Error creando submódulo 'Pagos externos': %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[SEED] Submódulo 'Pagos externos' creado")
|
||||
} else if sub.ModuleId != modulo.ID {
|
||||
db.Model(&sub).Update("module_id", modulo.ID)
|
||||
}
|
||||
var rol models.Roles
|
||||
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||
} else {
|
||||
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
|
||||
log.Printf("[SEED] Submódulo 'Pagos externos' asignado al rol 'Administrador'")
|
||||
}
|
||||
log.Println("[SEED] Seed de Pagos externos completado.")
|
||||
}
|
||||
|
||||
// SeedUmind registra el submódulo del panel de administración de uMind
|
||||
// (tenants, base de conocimiento y conversaciones del widget embebible).
|
||||
func SeedUmind() {
|
||||
db := app.Http.Database.DB
|
||||
var modulo models.Modules
|
||||
if err := db.Where("title = ?", "Automatización IA").First(&modulo).Error; err != nil {
|
||||
log.Println("[SEED] Módulo 'Automatización IA' no encontrado, se omite SeedUmind")
|
||||
return
|
||||
}
|
||||
// El panel viejo (Alpine, /app/umind) sigue existiendo y respondiendo, pero
|
||||
// el menú apunta al SPA. La URL pasó por /app/umind → /orchestrator →
|
||||
// /studio: se busca por cualquiera de las tres y se migra el mismo
|
||||
// registro, en vez de dejar submódulos duplicados en el menú.
|
||||
url := "/studio"
|
||||
var sub models.Submodules
|
||||
if err := db.Where("url IN ?", []string{url, "/orchestrator", "/app/umind"}).First(&sub).Error; err != nil {
|
||||
sub = models.Submodules{
|
||||
Title: "uMind Studio",
|
||||
Description: "Creación y gestión de agentes de IA: conocimiento, canales, herramientas y consumo",
|
||||
Url: url,
|
||||
ModuleId: modulo.ID,
|
||||
ModifiedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&sub).Error; err != nil {
|
||||
log.Printf("[SEED] Error creando submódulo 'uMind': %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[SEED] Submódulo 'uMind' creado")
|
||||
} else {
|
||||
updates := map[string]interface{}{}
|
||||
if sub.ModuleId != modulo.ID {
|
||||
updates["module_id"] = modulo.ID
|
||||
}
|
||||
if sub.Url != url {
|
||||
updates["url"] = url
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
db.Model(&sub).Updates(updates)
|
||||
}
|
||||
}
|
||||
// Planes de uMind: límite de agentes y precios por consumo. Solo staff.
|
||||
var subPlanes models.Submodules
|
||||
if err := db.Where("url = ?", "/app/umind-planes").First(&subPlanes).Error; err != nil {
|
||||
subPlanes = models.Submodules{
|
||||
Title: "Planes uMind",
|
||||
Description: "Límite de agentes y precios por consumo (IA, OCR, transcripción, mensualidad)",
|
||||
Url: "/app/umind-planes",
|
||||
ModuleId: modulo.ID,
|
||||
ModifiedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&subPlanes).Error; err != nil {
|
||||
log.Printf("[SEED] Error creando submódulo 'Planes uMind': %v", err)
|
||||
} else {
|
||||
log.Printf("[SEED] Submódulo 'Planes uMind' creado")
|
||||
}
|
||||
}
|
||||
|
||||
var rol models.Roles
|
||||
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||
} else {
|
||||
subs := []models.Submodules{sub}
|
||||
if subPlanes.ID > 0 {
|
||||
subs = append(subs, subPlanes)
|
||||
}
|
||||
asignarSubmodulosSiFaltan(&rol, subs)
|
||||
log.Printf("[SEED] Submódulos de uMind asignados al rol 'Administrador'")
|
||||
}
|
||||
log.Println("[SEED] Seed de uMind completado.")
|
||||
}
|
||||
|
||||
// SeedApiKeys registra el submódulo del panel de administración de API Keys
|
||||
// (credenciales scoped para /api/v2, alternativa al ADMIN_API_KEY maestro).
|
||||
func SeedApiKeys() {
|
||||
db := app.Http.Database.DB
|
||||
var modulo models.Modules
|
||||
if err := db.Where("title = ?", "Administración").First(&modulo).Error; err != nil {
|
||||
log.Println("[SEED] Módulo 'Administración' no encontrado, se omite SeedApiKeys")
|
||||
return
|
||||
}
|
||||
url := "/app/api-keys"
|
||||
var sub models.Submodules
|
||||
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
|
||||
sub = models.Submodules{
|
||||
Title: "API Keys",
|
||||
Description: "Credenciales scoped para /api/v2 (token + IP + alcance), alternativa a la llave maestra",
|
||||
Url: url,
|
||||
ModuleId: modulo.ID,
|
||||
ModifiedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&sub).Error; err != nil {
|
||||
log.Printf("[SEED] Error creando submódulo 'API Keys': %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[SEED] Submódulo 'API Keys' creado")
|
||||
} else if sub.ModuleId != modulo.ID {
|
||||
db.Model(&sub).Update("module_id", modulo.ID)
|
||||
}
|
||||
var rol models.Roles
|
||||
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
|
||||
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
|
||||
} else {
|
||||
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
|
||||
log.Printf("[SEED] Submódulo 'API Keys' asignado al rol 'Administrador'")
|
||||
}
|
||||
log.Println("[SEED] Seed de API Keys completado.")
|
||||
}
|
||||
|
||||
// MigrarUmindAgentes crea un agente "Principal" por cada UmindTenant que
|
||||
// todavía no tenga ninguno, heredando lo que antes vivía en el tenant
|
||||
// (site_key, config de IA, tono, mensaje de bienvenida, color) y mueve los
|
||||
// datos que ya tenía (documentos, chunks, tools, canales, conexiones,
|
||||
// mensajes) de tenant_id a agente_id. Idempotente: un tenant que ya tiene
|
||||
// al menos un agente se salta — así corre sola en cada arranque sin
|
||||
// duplicar nada, mismo criterio que los Seed* de este archivo.
|
||||
func MigrarUmindAgentes() {
|
||||
db := app.Http.Database.DB
|
||||
|
||||
var tenants []models.UmindTenant
|
||||
if err := db.Find(&tenants).Error; err != nil {
|
||||
log.Printf("[MIGRACION] Error leyendo umind_tenants: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
tablasConAgenteID := []string{
|
||||
"umind_documentos", "umind_chunks", "umind_herramientas",
|
||||
"umind_canales", "umind_conexiones", "umind_mensajes",
|
||||
}
|
||||
|
||||
for _, t := range tenants {
|
||||
var yaTieneAgente int64
|
||||
if err := db.Model(&models.UmindAgente{}).Where("tenant_id = ?", t.ID).Count(&yaTieneAgente).Error; err != nil {
|
||||
log.Printf("[MIGRACION] Error contando agentes del tenant %d: %v", t.ID, err)
|
||||
continue
|
||||
}
|
||||
if yaTieneAgente > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Las columnas viejas (site_key, ai_config_id, tono,
|
||||
// mensaje_bienvenida, color) ya no están en el struct Go UmindTenant,
|
||||
// pero AutoMigrate nunca las borró de la tabla — se leen directo.
|
||||
var viejo struct {
|
||||
SiteKey string
|
||||
AiConfigID *uint
|
||||
Tono string
|
||||
MensajeBienvenida string
|
||||
Color string
|
||||
}
|
||||
if err := db.Table("umind_tenants").
|
||||
Select("site_key, ai_config_id, tono, mensaje_bienvenida, color").
|
||||
Where("id = ?", t.ID).Scan(&viejo).Error; err != nil {
|
||||
log.Printf("[MIGRACION] Error leyendo datos viejos del tenant %d: %v", t.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
agente := models.UmindAgente{
|
||||
TenantID: t.ID,
|
||||
Nombre: "Principal",
|
||||
SiteKey: viejo.SiteKey,
|
||||
AiConfigID: viejo.AiConfigID,
|
||||
Tono: viejo.Tono,
|
||||
MensajeBienvenida: viejo.MensajeBienvenida,
|
||||
Color: viejo.Color,
|
||||
Activo: true,
|
||||
}
|
||||
if agente.SiteKey == "" {
|
||||
key, err := models.GenerarSiteKey()
|
||||
if err != nil {
|
||||
log.Printf("[MIGRACION] Error generando site_key para el agente del tenant %d: %v", t.ID, err)
|
||||
continue
|
||||
}
|
||||
agente.SiteKey = key
|
||||
}
|
||||
if agente.Color == "" {
|
||||
agente.Color = "#8eb02f"
|
||||
}
|
||||
if err := db.Create(&agente).Error; err != nil {
|
||||
log.Printf("[MIGRACION] Error creando agente Principal del tenant %d: %v", t.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, tabla := range tablasConAgenteID {
|
||||
sql := fmt.Sprintf("UPDATE %s SET agente_id = ? WHERE tenant_id = ? AND (agente_id IS NULL OR agente_id = 0)", tabla)
|
||||
if err := db.Exec(sql, agente.ID, t.ID).Error; err != nil {
|
||||
log.Printf("[MIGRACION] Error moviendo %s del tenant %d al agente %d: %v", tabla, t.ID, agente.ID, err)
|
||||
}
|
||||
}
|
||||
log.Printf("[MIGRACION] Tenant %d (%s): agente 'Principal' creado (id=%d), datos migrados", t.ID, t.Nombre, agente.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// LiberarColumnasHuerfanasUmind quita el NOT NULL de las columnas que dejaron
|
||||
// de mapearse cuando uMind pasó a multi-agente.
|
||||
//
|
||||
// GORM agrega columnas pero nunca las borra ni les cambia las restricciones.
|
||||
// Al sacar SiteKey de UmindTenant y renombrar TenantID→AgenteID en seis
|
||||
// tablas, las columnas viejas quedaron en la base CON su NOT NULL original.
|
||||
// El INSERT nuevo ya no las incluye, así que Postgres lo rechaza:
|
||||
//
|
||||
// null value in column "site_key" violates not-null constraint (23502)
|
||||
//
|
||||
// Sin esto no se puede crear un tenant, ni un documento, ni un chunk, ni
|
||||
// guardar un mensaje: es decir, uMind queda inutilizable después de
|
||||
// desplegar el refactor.
|
||||
//
|
||||
// No se hace DROP COLUMN a propósito: los datos viejos siguen ahí por si hay
|
||||
// que reconciliar algo. Solo se libera la restricción.
|
||||
// ColumnasHuerfanasUmind es la lista que recorre LiberarColumnasHuerfanasUmind.
|
||||
// Está afuera de la función para poder verificar en un test que cada nombre de
|
||||
// tabla coincide con el TableName() real del modelo: un typo acá haría que la
|
||||
// migración no encuentre la columna y no haga nada, en silencio.
|
||||
var ColumnasHuerfanasUmind = []struct{ Tabla, Columna string }{
|
||||
{"umind_tenants", "site_key"},
|
||||
{"umind_documentos", "tenant_id"},
|
||||
{"umind_chunks", "tenant_id"},
|
||||
{"umind_mensajes", "tenant_id"},
|
||||
{"umind_herramientas", "tenant_id"},
|
||||
{"umind_canales", "tenant_id"},
|
||||
{"umind_conexiones", "tenant_id"},
|
||||
}
|
||||
|
||||
func LiberarColumnasHuerfanasUmind() {
|
||||
db := app.Http.Database.DB
|
||||
|
||||
for _, h := range ColumnasHuerfanasUmind {
|
||||
// Se consulta information_schema en vez de intentar el ALTER a ciegas:
|
||||
// en una instalación nueva la columna no existe y el error sería ruido
|
||||
// en cada arranque.
|
||||
var nullable string
|
||||
err := db.Raw(`
|
||||
SELECT is_nullable FROM information_schema.columns
|
||||
WHERE table_name = ? AND column_name = ?
|
||||
`, h.Tabla, h.Columna).Scan(&nullable).Error
|
||||
if err != nil || nullable == "" || nullable == "YES" {
|
||||
continue
|
||||
}
|
||||
|
||||
sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP NOT NULL", h.Tabla, h.Columna)
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
log.Printf("[MIGRACION] no se pudo liberar %s.%s: %v", h.Tabla, h.Columna, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[MIGRACION] %s.%s ya no es NOT NULL (columna huérfana del refactor multi-agente)", h.Tabla, h.Columna)
|
||||
}
|
||||
}
|
||||
|
||||
// VerificarUrlsDeSubmodulos avisa en el arranque de los ítems del menú cuya
|
||||
// URL no corresponde a ninguna ruta registrada.
|
||||
//
|
||||
// Un submódulo es una URL escrita a mano (en un seed o desde /app/submodules).
|
||||
// Si no coincide con una ruta real, el ítem igual aparece en el menú y se le
|
||||
// puede asignar permiso a un rol — y recién al hacer clic aparece un 404, sin
|
||||
// nada que explique por qué. Esto lo convierte en una línea de log al
|
||||
// arrancar, en vez de un misterio en producción.
|
||||
//
|
||||
// Solo informa: no borra ni modifica nada, porque una URL "rota" puede ser una
|
||||
// ruta servida por otro lado.
|
||||
func VerificarUrlsDeSubmodulos(rutasGET map[string]bool) {
|
||||
var subs []models.Submodules
|
||||
if err := app.Http.Database.DB.Find(&subs).Error; err != nil {
|
||||
log.Printf("[MENU] no se pudieron revisar las URLs de los submódulos: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
rotos := 0
|
||||
for _, s := range subs {
|
||||
url := strings.TrimSpace(s.Url)
|
||||
if url == "" {
|
||||
log.Printf("[MENU] el submódulo %q (ID %d) no tiene URL", s.Title, s.ID)
|
||||
rotos++
|
||||
continue
|
||||
}
|
||||
if !rutasGET[url] {
|
||||
log.Printf("[MENU] el submódulo %q (ID %d) apunta a %q, que no es ninguna ruta: va a dar 404", s.Title, s.ID, url)
|
||||
rotos++
|
||||
}
|
||||
}
|
||||
if rotos == 0 {
|
||||
log.Printf("[MENU] %d submódulos revisados, todas las URLs resuelven", len(subs))
|
||||
} else {
|
||||
log.Printf("[MENU] %d de %d submódulos apuntan a una URL inexistente (ver líneas anteriores)", rotos, len(subs))
|
||||
}
|
||||
}
|
||||
|
||||
// SeedDocumentacion registra los submódulos de Documentación.
|
||||
//
|
||||
// Existían las rutas y las vistas pero nunca el seed, así que había que crear
|
||||
// el ítem del menú a mano desde /app/submodules — y una URL mal tipeada ahí es
|
||||
// indistinguible de un permiso mal asignado: en los dos casos se ve un 404.
|
||||
func SeedDocumentacion() {
|
||||
db := app.Http.Database.DB
|
||||
|
||||
var modulo models.Modules
|
||||
if err := db.Where("title = ?", "Documentación").First(&modulo).Error; err != nil {
|
||||
modulo = models.Modules{
|
||||
Title: "Documentación",
|
||||
Description: "Páginas y categorías de documentación interna",
|
||||
ModifiedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&modulo).Error; err != nil {
|
||||
log.Printf("[SEED] Error creando módulo Documentación: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
entries := []struct{ title, desc, url string }{
|
||||
{"Doc: Páginas", "Páginas de documentación", "/app/doc/paginas"},
|
||||
{"Doc: Categorías", "Categorías de documentación", "/app/doc/categorias"},
|
||||
}
|
||||
for _, e := range entries {
|
||||
var sub models.Submodules
|
||||
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
|
||||
sub = models.Submodules{
|
||||
Title: e.title,
|
||||
Description: e.desc,
|
||||
Url: e.url,
|
||||
ModuleId: modulo.ID,
|
||||
ModifiedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&sub).Error; err != nil {
|
||||
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IndicesUnicosMessageID crea índices únicos parciales sobre el Message-Id de
|
||||
// los correos ya procesados. Es lo que hace que un mismo correo no pueda abrir
|
||||
// dos tickets pase lo que pase: si el buzón vuelve a entregarlo, si el flag de
|
||||
// leído no se guardó, o si dos instancias de la app lo leen a la vez, la base
|
||||
// rechaza el segundo.
|
||||
//
|
||||
// Parcial (WHERE message_id <> ”) porque los tickets del portal no tienen
|
||||
// Message-Id y todos comparten la cadena vacía.
|
||||
func IndicesUnicosMessageID() {
|
||||
db := app.Http.Database.DB
|
||||
indices := []struct{ nombre, tabla string }{
|
||||
{"idx_tickets_message_id_unico", "proyecto_tickets"},
|
||||
{"idx_ticket_mensajes_message_id_unico", "ticket_mensajes"},
|
||||
}
|
||||
for _, ix := range indices {
|
||||
var existe bool
|
||||
if err := db.Raw(
|
||||
`SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = ?)`, ix.nombre,
|
||||
).Scan(&existe).Error; err != nil || existe {
|
||||
continue
|
||||
}
|
||||
// Si ya hay duplicados de antes, el índice no se puede crear. Se avisa y
|
||||
// se sigue: el chequeo previo en código igual filtra la mayoría.
|
||||
sql := fmt.Sprintf(
|
||||
`CREATE UNIQUE INDEX %s ON %s (message_id) WHERE message_id <> '' AND deleted_at IS NULL`,
|
||||
ix.nombre, ix.tabla)
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
log.Printf("[MIGRATE] No se pudo crear %s (¿hay correos duplicados de antes?): %v", ix.nombre, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[MIGRATE] Índice %s creado.", ix.nombre)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// El refactor multi-agente dejó columnas viejas con su NOT NULL original, y
|
||||
// GORM no las toca. El síntoma en producción es:
|
||||
//
|
||||
// null value in column "site_key" violates not-null constraint (23502)
|
||||
//
|
||||
// Si un nombre de tabla acá no coincide con el TableName() real, la migración
|
||||
// consulta information_schema, no encuentra nada y sigue de largo sin avisar:
|
||||
// el bug quedaría igual y el arranque se vería sano. Por eso se comparan
|
||||
// contra los modelos en vez de confiar en las cadenas escritas a mano.
|
||||
func TestTablasHuerfanasCoincidenConLosModelos(t *testing.T) {
|
||||
reales := map[string]bool{
|
||||
models.UmindTenant{}.TableName(): true,
|
||||
models.UmindDocumento{}.TableName(): true,
|
||||
models.UmindChunk{}.TableName(): true,
|
||||
models.UmindMensaje{}.TableName(): true,
|
||||
models.UmindHerramienta{}.TableName(): true,
|
||||
models.UmindCanal{}.TableName(): true,
|
||||
models.UmindConexion{}.TableName(): true,
|
||||
}
|
||||
|
||||
for _, h := range ColumnasHuerfanasUmind {
|
||||
if !reales[h.Tabla] {
|
||||
t.Errorf("la tabla %q no corresponde a ningún TableName() de uMind — la migración no encontraría la columna", h.Tabla)
|
||||
}
|
||||
}
|
||||
|
||||
// Las siete tablas afectadas tienen que estar cubiertas: si falta una,
|
||||
// insertar en ella sigue fallando.
|
||||
if len(ColumnasHuerfanasUmind) != len(reales) {
|
||||
t.Errorf("hay %d entradas para %d tablas afectadas", len(ColumnasHuerfanasUmind), len(reales))
|
||||
}
|
||||
}
|
||||
|
||||
// Las columnas huérfanas ya no deben existir en los structs: si alguna volvió
|
||||
// a mapearse, liberar su NOT NULL sería incorrecto.
|
||||
func TestLasColumnasHuerfanasYaNoSeMapean(t *testing.T) {
|
||||
// UmindTenant ya no debe tener SiteKey; vive en UmindAgente.
|
||||
if _, tiene := any(models.UmindAgente{}).(interface{ TableName() string }); !tiene {
|
||||
t.Skip("modelo inesperado")
|
||||
}
|
||||
a := models.UmindAgente{SiteKey: "umk_x"}
|
||||
if a.SiteKey != "umk_x" {
|
||||
t.Error("UmindAgente debería ser el dueño de SiteKey")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
|
||||
<title>uMind Studio</title>
|
||||
</head>
|
||||
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
|
||||
que son los que cambian con el tema. Una utilidad acá le ganaba a la
|
||||
capa base y dejaba el fondo claro también en modo oscuro. -->
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "umind-orchestrator",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import { tema, alternarTema } from './lib/tema.js'
|
||||
import { menuAbierto } from './lib/ui.js'
|
||||
import DespertarUmind from './components/DespertarUmind.vue'
|
||||
import UiIcono from './components/ui/UiIcono.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex">
|
||||
<DespertarUmind />
|
||||
<Sidebar />
|
||||
<main class="flex-1 min-w-0 flex flex-col">
|
||||
<header class="h-14 shrink-0 flex items-center gap-1 px-4 sm:px-6 border-b border-borde">
|
||||
<!-- Solo en móvil: en escritorio el sidebar está siempre visible. -->
|
||||
<button
|
||||
class="btn-ghost !px-2 !py-1.5 md:hidden"
|
||||
aria-label="Abrir menú"
|
||||
@click="menuAbierto = true"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="flex-1"></span>
|
||||
<button
|
||||
class="btn-ghost !px-2.5 !py-1.5"
|
||||
:title="tema === 'oscuro' ? 'Cambiar a claro' : 'Cambiar a oscuro'"
|
||||
@click="alternarTema"
|
||||
>
|
||||
<UiIcono :nombre="tema === 'oscuro' ? 'sol' : 'luna'" :tam="17" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="flex-1 max-w-5xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8 animate-aparecer">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
// El campo de puntos conectándose, una sola vez al entrar. Es el mismo lenguaje
|
||||
// visual de la página pública —puntos que se enlazan, como el espacio vectorial
|
||||
// donde vive el conocimiento del agente— así que refuerza la marca en vez de
|
||||
// inventar algo nuevo para adentro.
|
||||
//
|
||||
// Dura poco más de un segundo y se destruye. Un fondo animado permanente en una
|
||||
// herramienta de trabajo se ve bien diez segundos y molesta las ocho horas
|
||||
// siguientes, además de tener un canvas comiendo batería todo el día.
|
||||
const CLAVE = 'umind_despertar_visto'
|
||||
const DURACION = 1250
|
||||
|
||||
const lienzo = ref(null)
|
||||
const visible = ref(false)
|
||||
let raf = null
|
||||
let apagar = null
|
||||
|
||||
function quieto() {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Una vez por sesión: verlo en cada navegación del SPA sería exactamente el
|
||||
// fondo permanente que se quiere evitar.
|
||||
if (quieto() || sessionStorage.getItem(CLAVE)) return
|
||||
sessionStorage.setItem(CLAVE, '1')
|
||||
visible.value = true
|
||||
|
||||
// El apagado se programa ACÁ y no dentro del requestAnimationFrame: en una
|
||||
// pestaña de fondo el navegador no corre rAF, así que el timeout nunca
|
||||
// llegaba a programarse y el canvas quedaba puesto para siempre. Abrir el
|
||||
// Studio en una pestaña que no se está mirando alcanzaba para dejarlo tapado
|
||||
// con una capa invisible.
|
||||
apagar = setTimeout(() => (visible.value = false), DURACION)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const cv = lienzo.value
|
||||
if (!cv) return
|
||||
const ctx = cv.getContext('2d')
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const w = window.innerWidth
|
||||
const h = window.innerHeight
|
||||
cv.width = Math.round(w * dpr)
|
||||
cv.height = Math.round(h * dpr)
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
|
||||
// Menos puntos en pantallas chicas: en un teléfono la misma densidad se ve
|
||||
// sucia y cuesta más de dibujar.
|
||||
const cantidad = w < 640 ? 22 : 46
|
||||
const puntos = Array.from({ length: cantidad }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
r: 1 + Math.random() * 1.6,
|
||||
demora: Math.random() * 0.45,
|
||||
}))
|
||||
|
||||
const inicio = performance.now()
|
||||
|
||||
const pintar = (ahora) => {
|
||||
const t = Math.min(1, (ahora - inicio) / DURACION)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// Entra y sale en la misma curva: sin el desvanecido final el corte se
|
||||
// nota como un parpadeo.
|
||||
const opacidadGlobal = t < 0.75 ? 1 : 1 - (t - 0.75) / 0.25
|
||||
|
||||
for (let i = 0; i < puntos.length; i++) {
|
||||
const p = puntos[i]
|
||||
const avance = Math.max(0, Math.min(1, (t - p.demora) / 0.4))
|
||||
if (avance <= 0) continue
|
||||
|
||||
for (let j = i + 1; j < puntos.length; j++) {
|
||||
const q = puntos[j]
|
||||
const dx = p.x - q.x
|
||||
const dy = p.y - q.y
|
||||
const d = Math.sqrt(dx * dx + dy * dy)
|
||||
if (d > 170) continue
|
||||
ctx.strokeStyle = `rgba(142, 176, 47, ${0.16 * (1 - d / 170) * avance * opacidadGlobal})`
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(p.x, p.y)
|
||||
ctx.lineTo(q.x, q.y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, p.r * avance, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `rgba(142, 176, 47, ${0.5 * avance * opacidadGlobal})`
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
if (t < 1) raf = requestAnimationFrame(pintar)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(pintar)
|
||||
})
|
||||
})
|
||||
|
||||
// Si alguien navega antes de que termine, no queda un rAF girando ni un
|
||||
// timeout tocando un componente que ya no existe.
|
||||
onBeforeUnmount(() => {
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
if (apagar) clearTimeout(apagar)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- pointer-events-none: es decorativo y no puede robarle un click a nada. -->
|
||||
<canvas
|
||||
v-if="visible"
|
||||
ref="lienzo"
|
||||
class="fixed inset-0 w-full h-full pointer-events-none z-50"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
</template>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script setup>
|
||||
import UiIcono from './ui/UiIcono.vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
import { apiUmind, contexto } from '../lib/contexto.js'
|
||||
import { menuAbierto } from '../lib/ui.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tenants = ref([])
|
||||
const clientes = ref([])
|
||||
const planes = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const showForm = ref(false)
|
||||
const editing = ref(null)
|
||||
const form = ref(vacio())
|
||||
|
||||
// El tenant "activo" en la nav es tanto /tenants/:id como cualquier ruta
|
||||
// anidada de sus agentes (/tenants/:tenantId/agentes/:agenteId).
|
||||
const tenantActivoId = computed(() => route.params.tenantId || route.params.id)
|
||||
|
||||
// Al elegir un tenant en móvil el menú se cierra solo: dejarlo abierto tapa
|
||||
// justo la pantalla a la que se acaba de entrar.
|
||||
watch(() => route.fullPath, () => { menuAbierto.value = false })
|
||||
|
||||
function vacio() {
|
||||
return { nombre: '', dominios_permitidos: '', activo: true, cliente_id: null, plan_id: null }
|
||||
}
|
||||
|
||||
async function cargar() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const t = await api.get(apiUmind('/umind/tenants'))
|
||||
tenants.value = t.items || []
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Los endpoints del panel no comparten una forma de respuesta: el de clientes
|
||||
// devuelve el array pelado y el de planes lo envuelve en {items}. Aceptar las
|
||||
// dos evita que un select quede vacío sin que nadie se entere.
|
||||
function lista(r) {
|
||||
if (Array.isArray(r)) return r
|
||||
return r?.items || r?.registros || []
|
||||
}
|
||||
|
||||
// Clientes y planes solo los necesita el staff para asignarlos; en el portal
|
||||
// del cliente esos endpoints no se alcanzan y el formulario no los muestra.
|
||||
// Se cargan por separado a propósito: que falle uno no debe vaciar el otro.
|
||||
async function cargarAsignables() {
|
||||
if (contexto.esPortal) return
|
||||
const [c, p] = await Promise.allSettled([
|
||||
api.get('/app/api/clientes/select'),
|
||||
api.get('/app/umind-planes/list'),
|
||||
])
|
||||
clientes.value = c.status === 'fulfilled' ? lista(c.value) : []
|
||||
planes.value = p.status === 'fulfilled' ? lista(p.value) : []
|
||||
// Sin esto un fallo se veía igual que "no hay nada configurado", que es
|
||||
// justo lo que hizo que los campos Cliente y Plan no aparecieran.
|
||||
const fallos = []
|
||||
if (c.status === 'rejected') fallos.push('clientes')
|
||||
if (p.status === 'rejected') fallos.push('planes')
|
||||
if (fallos.length) error.value = `No se pudo cargar la lista de ${fallos.join(' ni ')}.`
|
||||
}
|
||||
|
||||
function nuevoTenant() {
|
||||
editing.value = null
|
||||
form.value = vacio()
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function editarTenant(t) {
|
||||
editing.value = t
|
||||
form.value = {
|
||||
nombre: t.nombre,
|
||||
dominios_permitidos: t.dominios_permitidos,
|
||||
activo: t.activo,
|
||||
cliente_id: t.cliente_id ?? null,
|
||||
plan_id: t.plan_id ?? null,
|
||||
}
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
async function guardar() {
|
||||
const payload = {
|
||||
...form.value,
|
||||
dominios_permitidos: form.value.dominios_permitidos
|
||||
.split(',')
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean),
|
||||
}
|
||||
try {
|
||||
if (editing.value) {
|
||||
await api.put(apiUmind(`/umind/tenants/${editing.value.ID}`), payload)
|
||||
showForm.value = false
|
||||
await cargar()
|
||||
} else {
|
||||
const r = await api.post(apiUmind('/umind/tenants'), payload)
|
||||
showForm.value = false
|
||||
await cargar()
|
||||
router.push(`/tenants/${r.id}`)
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function eliminar(t) {
|
||||
if (!confirm(`¿Eliminar el espacio "${t.nombre}"? Esto borra también sus agentes. No se puede deshacer.`)) return
|
||||
await api.del(apiUmind(`/umind/tenants/${t.ID}`))
|
||||
if (tenantActivoId.value === String(t.ID)) router.push('/')
|
||||
await cargar()
|
||||
}
|
||||
|
||||
defineExpose({ recargar: cargar })
|
||||
onMounted(() => {
|
||||
cargar()
|
||||
cargarAsignables()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Fondo oscuro en móvil: sin esto el menú abierto se superpone al
|
||||
contenido sin dejar claro que hay que cerrarlo. -->
|
||||
<div
|
||||
v-if="menuAbierto"
|
||||
class="fixed inset-0 bg-black/50 z-30 md:hidden"
|
||||
@click="menuAbierto = false"
|
||||
></div>
|
||||
|
||||
<aside
|
||||
class="w-64 shrink-0 flex flex-col border-r border-borde bg-superficie
|
||||
fixed inset-y-0 left-0 z-40 transition-transform duration-200
|
||||
md:static md:h-screen md:sticky md:top-0 md:translate-x-0"
|
||||
:class="menuAbierto ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="h-14 px-4 flex items-center gap-2 border-b border-borde">
|
||||
<router-link to="/" class="flex items-center gap-2 text-base font-semibold text-texto">
|
||||
<svg viewBox="0 0 96 96" class="w-6 h-6 shrink-0" aria-hidden="true">
|
||||
<rect width="96" height="96" rx="22" fill="#8eb02f" />
|
||||
<path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M60,58 V64" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" />
|
||||
<circle cx="60" cy="28" r="7" fill="#fff" />
|
||||
</svg>
|
||||
<span>uMind <span class="text-brand">Studio</span></span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="!contexto.esPortal" class="px-3 pt-3">
|
||||
<button
|
||||
class="btn-primary w-full"
|
||||
@click="nuevoTenant"
|
||||
>
|
||||
+ Nuevo espacio
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="px-3 pt-2 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
|
||||
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5">
|
||||
<p v-if="loading" class="px-2 text-xs text-tenue">Cargando...</p>
|
||||
<p v-else-if="tenants.length === 0" class="px-2 text-xs text-tenue">
|
||||
{{ contexto.esPortal ? 'Todavía no tenés ningún espacio asignado. Escribinos y lo activamos.' : 'Sin espacios todavía.' }}
|
||||
</p>
|
||||
<div
|
||||
v-for="t in tenants"
|
||||
:key="t.ID"
|
||||
class="group flex items-center rounded-lg transition-colors"
|
||||
:class="tenantActivoId === String(t.ID) ? 'bg-brand/10' : 'hover:bg-elevado'"
|
||||
>
|
||||
<router-link
|
||||
:to="`/tenants/${t.ID}`"
|
||||
class="flex-1 min-w-0 px-2.5 py-2 text-sm"
|
||||
:class="tenantActivoId === String(t.ID) ? 'text-brand font-medium' : 'text-texto'"
|
||||
>
|
||||
<div class="truncate">{{ t.nombre }}</div>
|
||||
<div class="flex items-center gap-1 mt-0.5">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="t.activo ? 'bg-green-500' : 'bg-tenue/40'"></span>
|
||||
<span class="text-[11px] text-tenue">{{ t.activo ? 'activo' : 'inactivo' }}</span>
|
||||
</div>
|
||||
</router-link>
|
||||
<div v-if="!contexto.esPortal" class="flex opacity-0 group-hover:opacity-100 transition-opacity pr-1.5 gap-0.5">
|
||||
<button
|
||||
class="p-1 text-tenue hover:text-texto"
|
||||
title="Editar"
|
||||
@click="editarTenant(t)"
|
||||
>
|
||||
<UiIcono nombre="lapiz" :tam="14" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-tenue hover:text-red-600"
|
||||
title="Eliminar"
|
||||
@click="eliminar(t)"
|
||||
>
|
||||
<UiIcono nombre="cerrar" :tam="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Modal de alta/edición -->
|
||||
<div
|
||||
v-if="showForm"
|
||||
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
|
||||
@click.self="showForm = false"
|
||||
>
|
||||
<div class="card w-full max-w-lg p-6 animate-escalar shadow-2xl">
|
||||
<h2 class="font-semibold text-texto mb-4">
|
||||
{{ editing ? 'Editar espacio' : 'Nuevo espacio' }}
|
||||
</h2>
|
||||
<p class="text-xs text-tenue mb-3">
|
||||
Un espacio es el negocio o sitio dueño de los dominios permitidos. La config de IA, tono y demás se configuran por agente, dentro del tenant.
|
||||
</p>
|
||||
<form class="space-y-3" @submit.prevent="guardar">
|
||||
<div>
|
||||
<label class="label">Nombre</label>
|
||||
<input
|
||||
v-model="form.nombre"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Dominios permitidos (separados por coma)</label>
|
||||
<input
|
||||
v-model="form.dominios_permitidos"
|
||||
placeholder="ejemplo.com, www.ejemplo.com"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!contexto.esPortal" class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">Cliente</label>
|
||||
<select
|
||||
v-model="form.cliente_id"
|
||||
class="input"
|
||||
>
|
||||
<option :value="null">— sin asignar —</option>
|
||||
<option v-for="c in clientes" :key="c.ID" :value="c.ID">{{ c.nombre }}</option>
|
||||
</select>
|
||||
<p class="text-[11px] text-tenue mt-1">
|
||||
{{ clientes.length ? 'Define quién ve este espacio desde el portal.' : 'No hay clientes activos — creá uno en Clientes.' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Plan</label>
|
||||
<select
|
||||
v-model="form.plan_id"
|
||||
class="input"
|
||||
>
|
||||
<option :value="null">— sin plan —</option>
|
||||
<option v-for="p in planes" :key="p.ID" :value="p.ID">
|
||||
{{ p.nombre }} ({{ p.max_agentes === 0 ? '∞' : p.max_agentes }} agentes)
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-[11px] text-tenue mt-1">
|
||||
{{ planes.length ? 'Límite de agentes y precios de consumo.' : 'No hay planes — creá uno en uMind Planes.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-texto">
|
||||
<input v-model="form.activo" type="checkbox" />
|
||||
Activo
|
||||
</label>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" class="btn-ghost" @click="showForm = false">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn-primary">
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
// tipo: ok | alerta | error | neutro
|
||||
defineProps({ tipo: { type: String, default: 'neutro' }, punto: Boolean })
|
||||
const clases = { ok: 'badge-ok', alerta: 'badge-alerta', error: 'badge-error', neutro: 'badge-neutro' }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="clases[tipo] || 'badge-neutro'">
|
||||
<span
|
||||
v-if="punto"
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="{ ok: 'bg-green-500', alerta: 'bg-amber-500', error: 'bg-red-500', neutro: 'bg-gray-400' }[tipo]"
|
||||
></span>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import UiMascota from './UiMascota.vue'
|
||||
|
||||
// Una pantalla vacía es la primera que ve alguien que recién empieza, y la que
|
||||
// más veces ve el que todavía no configuró algo. Decir "Sin canales" y nada
|
||||
// más deja al usuario resolviendo solo qué significa eso y qué hacer.
|
||||
//
|
||||
// La mascota lleva el estado: dormida cuando no pasó nada todavía, buscando
|
||||
// cuando falta cargar información, alerta cuando algo se rompió. Se lee antes
|
||||
// que el texto.
|
||||
defineProps({
|
||||
// Estado de Umi. Sin él, cae en 'durmiendo', que es el caso más común.
|
||||
estado: { type: String, default: 'durmiendo' },
|
||||
titulo: String,
|
||||
detalle: String,
|
||||
// Escape para los pocos casos donde un emoji dice más que la mascota.
|
||||
icono: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center text-center py-12 px-6">
|
||||
<div v-if="icono" class="text-3xl mb-3 opacity-70">{{ icono }}</div>
|
||||
<UiMascota v-else :estado="estado" :tam="64" class="mb-3 text-brand" />
|
||||
|
||||
<p class="text-sm font-medium text-texto">{{ titulo }}</p>
|
||||
<p v-if="detalle" class="text-xs text-tenue mt-1.5 max-w-sm leading-relaxed">{{ detalle }}</p>
|
||||
<div v-if="$slots.default" class="mt-4"><slot /></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
// Un solo set de iconos para todo uMind.
|
||||
//
|
||||
// Antes convivían emojis a color (🌙 📊 🧠 ✍️) con símbolos tipográficos
|
||||
// monocromos (✎ ✕ ← ↻). Dos lenguajes distintos en la misma pantalla se leen
|
||||
// como descuido, y encima los emojis los dibuja el sistema operativo: el mismo
|
||||
// panel se ve distinto en Mac, en Windows y en Android, sin que podamos hacer
|
||||
// nada.
|
||||
//
|
||||
// Estos son SVG de trazo que heredan currentColor, así que toman el color del
|
||||
// texto que los rodea y funcionan igual en tema claro y oscuro. Mismo grosor de
|
||||
// línea y misma caja en todos: al lado uno de otro tienen el mismo peso visual.
|
||||
defineProps({
|
||||
nombre: { type: String, required: true },
|
||||
tam: { type: [Number, String], default: 16 },
|
||||
})
|
||||
|
||||
// Cada icono es una lista de [etiqueta, atributos]. No es una cadena de HTML a
|
||||
// propósito: v-html dentro de un <svg> parsea como HTML y los <path> quedan sin
|
||||
// el namespace de SVG — se insertan en el DOM pero no dibujan nada. Declarados
|
||||
// así, Vue los crea con el namespace correcto porque ve el <svg> padre.
|
||||
const ICONOS = {
|
||||
luna: [['path', { d: 'M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z' }]],
|
||||
sol: [
|
||||
['circle', { cx: 12, cy: 12, r: 4 }],
|
||||
['path', { d: 'M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4' }],
|
||||
],
|
||||
lapiz: [
|
||||
['path', { d: 'M12 20h9' }],
|
||||
['path', { d: 'M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z' }],
|
||||
],
|
||||
cerrar: [['path', { d: 'M18 6 6 18M6 6l12 12' }]],
|
||||
atras: [['path', { d: 'M19 12H5M12 19l-7-7 7-7' }]],
|
||||
grafico: [
|
||||
['path', { d: 'M3 3v18h18' }],
|
||||
['path', { d: 'M7 15v3M12 10v8M17 6v12' }],
|
||||
],
|
||||
cerebro: [
|
||||
['path', { d: 'M9.5 3A2.5 2.5 0 0 0 7 5.5v.2A2.7 2.7 0 0 0 5 8.3c0 .8.3 1.5.9 2A2.8 2.8 0 0 0 5 12.5c0 1 .5 1.9 1.3 2.4-.2.4-.3.8-.3 1.2A2.9 2.9 0 0 0 9 19a2.5 2.5 0 0 0 3 2V3.8A2.5 2.5 0 0 0 9.5 3Z' }],
|
||||
['path', { d: 'M14.5 3A2.5 2.5 0 0 1 17 5.5v.2a2.7 2.7 0 0 1 2 2.6c0 .8-.3 1.5-.9 2A2.8 2.8 0 0 1 19 12.5c0 1-.5 1.9-1.3 2.4.2.4.3.8.3 1.2A2.9 2.9 0 0 1 15 19a2.5 2.5 0 0 1-3 2' }],
|
||||
],
|
||||
imagen: [
|
||||
['rect', { x: 3, y: 3, width: 18, height: 18, rx: 2 }],
|
||||
['circle', { cx: 8.5, cy: 8.5, r: 1.5 }],
|
||||
['path', { d: 'm21 15-5-5L5 21' }],
|
||||
],
|
||||
microfono: [
|
||||
['rect', { x: 9, y: 2, width: 6, height: 12, rx: 3 }],
|
||||
['path', { d: 'M5 10a7 7 0 0 0 14 0M12 17v5' }],
|
||||
],
|
||||
escribir: [
|
||||
['path', { d: 'M3 21h18' }],
|
||||
['path', { d: 'M5 17 17.5 4.5a2.1 2.1 0 0 1 3 3L8 20l-4 1Z' }],
|
||||
],
|
||||
archivo: [
|
||||
['path', { d: 'M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z' }],
|
||||
['path', { d: 'M14 2v5h5' }],
|
||||
],
|
||||
sitio: [
|
||||
['circle', { cx: 12, cy: 12, r: 9 }],
|
||||
['path', { d: 'M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18Z' }],
|
||||
],
|
||||
refrescar: [
|
||||
['path', { d: 'M21 12a9 9 0 1 1-3-6.7' }],
|
||||
['path', { d: 'M21 4v5h-5' }],
|
||||
],
|
||||
descargar: [
|
||||
['path', { d: 'M12 3v12' }],
|
||||
['path', { d: 'm7 11 5 5 5-5' }],
|
||||
['path', { d: 'M4 21h16' }],
|
||||
],
|
||||
alerta: [
|
||||
['path', { d: 'M12 9v4M12 17h.01' }],
|
||||
['path', { d: 'M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z' }],
|
||||
],
|
||||
ok: [['path', { d: 'M20 6 9 17l-5-5' }]],
|
||||
copiar: [
|
||||
['rect', { x: 9, y: 9, width: 12, height: 12, rx: 2 }],
|
||||
['path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' }],
|
||||
],
|
||||
mensaje: [['path', { d: 'M21 11.5a8.4 8.4 0 0 1-9 8.4 8.5 8.5 0 0 1-3.9-.9L3 21l1.9-5.1A8.4 8.4 0 0 1 4 11.5a8.5 8.5 0 0 1 8.5-8.5A8.4 8.4 0 0 1 21 11.5Z' }]],
|
||||
auto: [
|
||||
['path', { d: 'M17 2.1 21 6l-4 3.9' }],
|
||||
['path', { d: 'M3 11V9a4 4 0 0 1 4-4h14' }],
|
||||
['path', { d: 'M7 21.9 3 18l4-3.9' }],
|
||||
['path', { d: 'M21 13v2a4 4 0 0 1-4 4H3' }],
|
||||
],
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="tam"
|
||||
:height="tam"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="inline-block shrink-0 align-[-0.15em]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<component
|
||||
v-for="(el, i) in ICONOS[nombre] || []"
|
||||
:key="i"
|
||||
:is="el[0]"
|
||||
v-bind="el[1]"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup>
|
||||
// Umi, la mascota de uMind.
|
||||
//
|
||||
// No es un dibujo suelto: sale del logo. El cuerpo es la "u" del monograma y
|
||||
// el punto del logo pasa a ser su antena, así que el símbolo de la marca y el
|
||||
// personaje son la misma forma vista dos veces. Por eso funciona al lado del
|
||||
// logo sin competirle.
|
||||
//
|
||||
// El estado no es decoración: cada pantalla vacía dice algo distinto, y la
|
||||
// cara lo dice antes que el texto. Dormida cuando no pasó nada todavía,
|
||||
// buscando cuando falta cargarle información, alerta cuando algo se rompió.
|
||||
defineProps({
|
||||
estado: {
|
||||
type: String,
|
||||
default: 'normal', // normal | durmiendo | contenta | pensando | buscando | alerta
|
||||
},
|
||||
tam: { type: [Number, String], default: 72 },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="tam"
|
||||
:height="tam"
|
||||
viewBox="0 0 120 120"
|
||||
class="shrink-0"
|
||||
role="img"
|
||||
:aria-label="`Umi, la mascota de uMind (${estado})`"
|
||||
>
|
||||
<!-- Antena: el punto del logo. Cambia de color solo cuando algo anda mal,
|
||||
para que el rojo signifique algo. -->
|
||||
<circle
|
||||
cx="60"
|
||||
cy="26"
|
||||
r="6"
|
||||
:fill="estado === 'alerta' ? '#dc2626' : 'currentColor'"
|
||||
:class="estado === 'pensando' ? 'umi-late' : ''"
|
||||
/>
|
||||
<line
|
||||
x1="60" y1="32" x2="60" y2="44"
|
||||
:stroke="estado === 'alerta' ? '#dc2626' : 'currentColor'"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<!-- Cuerpo: la "u" del logo, cerrada. -->
|
||||
<path d="M30,46 V76 A30,30 0 0 0 90,76 V46 Z" fill="currentColor" />
|
||||
|
||||
<!-- Ojos abiertos -->
|
||||
<template v-if="estado === 'normal' || estado === 'contenta' || estado === 'alerta'">
|
||||
<circle cx="47" :cy="estado === 'contenta' ? 64 : 66" r="6" class="umi-ojo" />
|
||||
<circle cx="73" :cy="estado === 'contenta' ? 64 : 66" r="6" class="umi-ojo" />
|
||||
<circle cx="48" :cy="estado === 'contenta' ? 65 : 67" r="3" class="umi-pupila" />
|
||||
<circle cx="74" :cy="estado === 'contenta' ? 65 : 67" r="3" class="umi-pupila" />
|
||||
</template>
|
||||
|
||||
<!-- Ojos cerrados: no pasó nada todavía, no hay nada roto -->
|
||||
<template v-else-if="estado === 'durmiendo'">
|
||||
<path d="M41,66 h12 M67,66 h12" class="umi-linea" stroke-width="5" stroke-linecap="round" fill="none" />
|
||||
</template>
|
||||
|
||||
<!-- Pensando: los tres puntos de "escribiendo…" que ya conoce cualquiera -->
|
||||
<template v-else-if="estado === 'pensando'">
|
||||
<circle cx="45" cy="66" r="4.5" class="umi-ojo umi-p1" />
|
||||
<circle cx="60" cy="66" r="4.5" class="umi-ojo umi-p2" />
|
||||
<circle cx="75" cy="66" r="4.5" class="umi-ojo umi-p3" />
|
||||
</template>
|
||||
|
||||
<!-- Buscando: mira de costado, como quien revisa algo -->
|
||||
<template v-else-if="estado === 'buscando'">
|
||||
<circle cx="47" cy="66" r="6" class="umi-ojo" />
|
||||
<circle cx="73" cy="66" r="6" class="umi-ojo" />
|
||||
<circle cx="50" cy="66" r="3" class="umi-pupila" />
|
||||
<circle cx="76" cy="66" r="3" class="umi-pupila" />
|
||||
</template>
|
||||
|
||||
<!-- Sonrisa solo cuando hay algo que celebrar -->
|
||||
<path
|
||||
v-if="estado === 'contenta'"
|
||||
d="M50,78 q10,8 20,0"
|
||||
class="umi-linea"
|
||||
stroke-width="4"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Los ojos toman el color de la superficie que tienen detrás, no blanco fijo:
|
||||
así la mascota se apoya sobre cualquier tarjeta sin un halo alrededor de
|
||||
cada ojo, y en modo oscuro no quedan dos puntos blancos flotando.
|
||||
Los tokens del proyecto son tripletes RGB, de ahí el rgb(). */
|
||||
.umi-ojo { fill: rgb(var(--superficie)); }
|
||||
.umi-linea { stroke: rgb(var(--superficie)); }
|
||||
|
||||
/* La pupila sí es fija: es lo único que da la sensación de mirada, y tiene
|
||||
que leerse igual sobre el verde en los dos temas. */
|
||||
.umi-pupila { fill: #11150F; }
|
||||
|
||||
.umi-late { animation: umi-latido 2s ease-in-out infinite; }
|
||||
@keyframes umi-latido {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.umi-p1, .umi-p2, .umi-p3 { animation: umi-punto 1.4s ease-in-out infinite; }
|
||||
.umi-p2 { animation-delay: 0.18s; }
|
||||
.umi-p3 { animation-delay: 0.36s; }
|
||||
@keyframes umi-punto {
|
||||
0%, 60%, 100% { opacity: 0.35; }
|
||||
30% { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.umi-late, .umi-p1, .umi-p2, .umi-p3 { animation: none; opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import UiIcono from './UiIcono.vue'
|
||||
defineProps({ titulo: { type: String, default: '' }, ancho: { type: String, default: 'max-w-lg' } })
|
||||
const emit = defineEmits(['cerrar'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
@click.self="emit('cerrar')"
|
||||
@keydown.esc="emit('cerrar')"
|
||||
>
|
||||
<div :class="['card w-full p-6 max-h-[90vh] overflow-y-auto animate-escalar shadow-2xl', ancho]">
|
||||
<div v-if="titulo" class="flex items-center justify-between mb-4">
|
||||
<h2 class="font-semibold text-texto">{{ titulo }}</h2>
|
||||
<button class="btn-ghost !px-2 !py-1" type="button" @click="emit('cerrar')"><UiIcono nombre="cerrar" :tam="15" /></button>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
import { contexto } from './contexto.js'
|
||||
|
||||
// Wrapper de fetch para las rutas de sesión de uMind. La cookie de sesión
|
||||
// viaja sola por ser mismo origen. Si la sesión expiró, tanto AuthWeb() como
|
||||
// PortalAuth() redirigen al login devolviendo HTML en vez de un 401 JSON —
|
||||
// fetch sigue ese redirect solo, así que lo detectamos por el content-type.
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(path, {
|
||||
...options,
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
})
|
||||
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
if (res.redirected || !contentType.includes('application/json')) {
|
||||
window.location.href = contexto.urlLogin
|
||||
throw new Error('Sesión expirada')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
// El backend responde {"error": "..."} pero algunos handlers viejos usan
|
||||
// {"error": true, "message": "..."} — sin este chequeo el usuario veía
|
||||
// literalmente "true" como mensaje de error.
|
||||
const msg = typeof data?.error === 'string' ? data.error : data?.message
|
||||
throw new Error(msg || 'Error de servidor')
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (path) => request(path),
|
||||
post: (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||
put: (path, body) => request(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
del: (path) => request(path, { method: 'DELETE' }),
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// El mismo build se sirve en dos lugares: /orchestrator (staff, sesión de
|
||||
// panel) y /portal/studio (cliente, sesión de portal). Ambos exponen los
|
||||
// mismos endpoints de uMind pero bajo distinto prefijo y con distinto
|
||||
// alcance, así que la app deduce dónde está parada mirando la URL.
|
||||
const enPortal = window.location.pathname.startsWith('/portal/')
|
||||
|
||||
export const contexto = {
|
||||
esPortal: enPortal,
|
||||
// Base del router (vue-router en modo history).
|
||||
baseRuta: enPortal ? '/portal/studio/' : '/studio/',
|
||||
// Prefijo de la API de uMind.
|
||||
apiBase: enPortal ? '/portal' : '/app',
|
||||
// A dónde mandar al usuario cuando se le venció la sesión.
|
||||
urlLogin: enPortal ? '/portal/login' : '/login',
|
||||
}
|
||||
|
||||
// apiUmind arma la ruta de un endpoint de uMind para el contexto actual:
|
||||
// apiUmind('/umind/agentes') → '/app/umind/agentes' o '/portal/umind/agentes'
|
||||
export function apiUmind(path) {
|
||||
return contexto.apiBase + path
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// El tema se aplica antes de montar la app para que no haya un parpadeo
|
||||
// claro en quien tiene el oscuro elegido.
|
||||
const GUARDADO = 'umind-tema'
|
||||
|
||||
function preferidoDelSistema() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'oscuro' : 'claro'
|
||||
}
|
||||
|
||||
export const tema = ref(localStorage.getItem(GUARDADO) || preferidoDelSistema())
|
||||
|
||||
export function aplicarTema() {
|
||||
document.documentElement.classList.toggle('dark', tema.value === 'oscuro')
|
||||
}
|
||||
|
||||
export function alternarTema() {
|
||||
tema.value = tema.value === 'oscuro' ? 'claro' : 'oscuro'
|
||||
localStorage.setItem(GUARDADO, tema.value)
|
||||
aplicarTema()
|
||||
}
|
||||
|
||||
aplicarTema()
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Estado del menú lateral en móvil. Vive acá y no en App.vue porque lo tocan
|
||||
// dos componentes que no son padre/hijo: el botón del header y el propio
|
||||
// sidebar, que se cierra solo al navegar.
|
||||
export const menuAbierto = ref(false)
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router.js'
|
||||
import './style.css'
|
||||
import './lib/tema.js' // aplica el tema antes de montar, para no parpadear
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Home from './views/Home.vue'
|
||||
import TenantAgentes from './views/TenantAgentes.vue'
|
||||
import AgenteDetail from './views/AgenteDetail.vue'
|
||||
import Uso from './views/Uso.vue'
|
||||
import AiPropia from './views/AiPropia.vue'
|
||||
import { contexto } from './lib/contexto.js'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(contexto.baseRuta),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: Home },
|
||||
{ path: '/tenants/:id', name: 'tenant-agentes', component: TenantAgentes, props: true },
|
||||
{ path: '/tenants/:id/uso', name: 'uso', component: Uso, props: true },
|
||||
{ path: '/tenants/:id/ia', name: 'ai-propia', component: AiPropia, props: true },
|
||||
{
|
||||
path: '/tenants/:tenantId/agentes/:agenteId',
|
||||
name: 'agente-detail',
|
||||
component: AgenteDetail,
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,145 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Los colores viven acá una sola vez, en claro y oscuro. Antes cada elemento
|
||||
repetía el par (bg-white dark:bg-gray-900, border-gray-200 dark:border-...)
|
||||
a mano en cientos de lugares; cambiar un tono era buscar y reemplazar. */
|
||||
@layer base {
|
||||
:root {
|
||||
--superficie: 255 255 255;
|
||||
--elevado: 249 250 251;
|
||||
--borde: 229 231 235;
|
||||
--texto: 31 41 55;
|
||||
--tenue: 107 114 128;
|
||||
--fondo: 249 250 251;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--superficie: 17 24 39;
|
||||
--elevado: 24 33 51;
|
||||
--borde: 42 52 70;
|
||||
--texto: 229 231 235;
|
||||
--tenue: 148 163 184;
|
||||
--fondo: 9 13 22;
|
||||
}
|
||||
|
||||
body {
|
||||
background: rgb(var(--fondo));
|
||||
color: rgb(var(--texto));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Retícula de plano técnico, muy tenue y ESTÁTICA. Da profundidad sin pedir
|
||||
atención ni gastar un canvas corriendo todo el día. Se desvanece hacia
|
||||
abajo para no competir con las tablas y los formularios, que es donde de
|
||||
verdad se trabaja. */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgb(var(--borde) / 0.5) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(var(--borde) / 0.5) 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
-webkit-mask-image: radial-gradient(ellipse 90% 55% at 50% 0%, #000 10%, transparent 70%);
|
||||
mask-image: radial-gradient(ellipse 90% 55% at 50% 0%, #000 10%, transparent 70%);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* El contenido va por encima de la retícula. */
|
||||
#app { position: relative; z-index: 1; }
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-superficie border border-borde rounded-xl;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full bg-superficie border border-borde text-texto rounded-lg px-3 py-2 text-sm
|
||||
placeholder:text-tenue/60 outline-none transition-colors
|
||||
focus:border-brand focus:ring-2 focus:ring-brand/20;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-xs font-medium text-tenue mb-1;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium
|
||||
transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-brand text-white hover:bg-brand-dark;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply btn text-tenue hover:text-texto hover:bg-elevado;
|
||||
}
|
||||
.btn-peligro {
|
||||
@apply btn text-red-600 hover:bg-red-50 dark:hover:bg-red-950/40;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
.badge-ok {
|
||||
@apply badge bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-400;
|
||||
}
|
||||
.badge-alerta {
|
||||
@apply badge bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400;
|
||||
}
|
||||
.badge-error {
|
||||
@apply badge bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-400;
|
||||
}
|
||||
.badge-neutro {
|
||||
@apply badge bg-elevado text-tenue;
|
||||
}
|
||||
|
||||
.tab {
|
||||
@apply px-3 py-1.5 rounded-full text-sm transition-colors whitespace-nowrap;
|
||||
}
|
||||
.tab-activo {
|
||||
@apply tab bg-brand text-white;
|
||||
}
|
||||
.tab-inactivo {
|
||||
@apply tab text-tenue hover:text-texto hover:bg-elevado;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Deslizable con el dedo, sin la barra gris encima del contenido. El borde
|
||||
cortado de la última pestaña ya avisa que hay más hacia el costado. */
|
||||
.sin-barra {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.sin-barra::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Un punto que late para lo que está trabajando de verdad: una fuente en
|
||||
"procesando" con la etiqueta quieta no dice si sigue avanzando o si se
|
||||
colgó. Se usa sólo donde hay trabajo real en curso, nunca de adorno. */
|
||||
.latido {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 9999px;
|
||||
background: currentColor;
|
||||
animation: latido 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes latido {
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.latido { animation: none; opacity: 0.8; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
import { apiUmind, contexto } from '../lib/contexto.js'
|
||||
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||
import UiIcono from '../components/ui/UiIcono.vue'
|
||||
import TabAuditoria from './agente/TabAuditoria.vue'
|
||||
import TabConexiones from './agente/TabConexiones.vue'
|
||||
import TabChat from './agente/TabChat.vue'
|
||||
import TabConversaciones from './agente/TabConversaciones.vue'
|
||||
import TabHerramientas from './agente/TabHerramientas.vue'
|
||||
import TabCanales from './agente/TabCanales.vue'
|
||||
import EstadoAgente from './agente/EstadoAgente.vue'
|
||||
|
||||
const props = defineProps({
|
||||
tenantId: { type: String, required: true },
|
||||
agenteId: { type: String, required: true },
|
||||
})
|
||||
const agenteIdNum = computed(() => Number(props.agenteId))
|
||||
const route = useRoute()
|
||||
|
||||
const agente = ref(null)
|
||||
const error = ref('')
|
||||
const tab = ref(
|
||||
typeof route.query.tab === 'string'
|
||||
? route.query.tab
|
||||
: contexto.esPortal
|
||||
? 'conversaciones'
|
||||
: 'conocimiento',
|
||||
)
|
||||
|
||||
// ─── Base de conocimiento ───────────────────────────────────────────────────
|
||||
const documentos = ref([])
|
||||
const nuevaUrl = ref('')
|
||||
const maxPaginas = ref(30)
|
||||
const ingestando = ref(false)
|
||||
|
||||
async function cargarAgente() {
|
||||
const r = await api.get(apiUmind(`/umind/agentes?tenant_id=${props.tenantId}`))
|
||||
agente.value = (r.items || []).find((x) => String(x.ID) === props.agenteId) || null
|
||||
}
|
||||
|
||||
async function cargarDocumentos() {
|
||||
const r = await api.get(apiUmind(`/umind/documentos?agente_id=${props.agenteId}`))
|
||||
documentos.value = r.items || []
|
||||
}
|
||||
|
||||
// Tres formas de cargar conocimiento, no una: la web, un archivo (lista de
|
||||
// precios, condiciones) y lo que el dueño escribe a mano, que es lo más
|
||||
// valioso y lo único que no está en ningún documento.
|
||||
const fuenteNueva = ref('texto')
|
||||
const autoActualizar = ref(true)
|
||||
const notaTitulo = ref('')
|
||||
const notaTexto = ref('')
|
||||
const archivoRef = ref(null)
|
||||
|
||||
async function agregarFuente() {
|
||||
if (!nuevaUrl.value.trim()) return
|
||||
ingestando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.post(apiUmind('/umind/documentos'), {
|
||||
agente_id: agenteIdNum.value,
|
||||
url: nuevaUrl.value.trim(),
|
||||
max_paginas: Number(maxPaginas.value) || 30,
|
||||
auto_actualizar: autoActualizar.value,
|
||||
})
|
||||
nuevaUrl.value = ''
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
ingestando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function agregarNota() {
|
||||
if (!notaTexto.value.trim()) return
|
||||
ingestando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.post(apiUmind('/umind/documentos/texto'), {
|
||||
agente_id: agenteIdNum.value,
|
||||
titulo: notaTitulo.value.trim(),
|
||||
contenido: notaTexto.value,
|
||||
})
|
||||
notaTitulo.value = ''
|
||||
notaTexto.value = ''
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
ingestando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function subirArchivo() {
|
||||
const f = archivoRef.value?.files?.[0]
|
||||
if (!f) return
|
||||
ingestando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('agente_id', String(agenteIdNum.value))
|
||||
fd.append('archivo', f)
|
||||
// Sin Content-Type a mano: el navegador tiene que poner el boundary.
|
||||
const res = await fetch(apiUmind('/umind/documentos/archivo'), { method: 'POST', body: fd })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'No se pudo subir')
|
||||
archivoRef.value.value = ''
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
ingestando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Editar una fuente escrita a mano. Las plantillas de rubro dejan las notas
|
||||
// con valores entre corchetes para reemplazar — sin esto, no había con qué.
|
||||
const editandoDoc = ref(null)
|
||||
const docForm = ref({ titulo: '', contenido: '' })
|
||||
const guardandoDoc = ref(false)
|
||||
|
||||
function editarDocumento(d) {
|
||||
editandoDoc.value = d
|
||||
docForm.value = { titulo: d.origen, contenido: d.contenido || '' }
|
||||
}
|
||||
|
||||
async function guardarDocumento() {
|
||||
if (!editandoDoc.value) return
|
||||
guardandoDoc.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.put(apiUmind(`/umind/documentos/${editandoDoc.value.ID}`), {
|
||||
titulo: docForm.value.titulo,
|
||||
contenido: docForm.value.contenido,
|
||||
})
|
||||
editandoDoc.value = null
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
guardandoDoc.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reprocesar(d) {
|
||||
error.value = ''
|
||||
try {
|
||||
await api.post(apiUmind(`/umind/documentos/${d.ID}/reprocesar`), {})
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function alternarAuto(d) {
|
||||
try {
|
||||
await api.put(apiUmind(`/umind/documentos/${d.ID}`), { auto_actualizar: !d.auto_actualizar })
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
// "hace 3 días" en vez de una fecha: lo que importa no es cuándo se procesó
|
||||
// sino qué tan viejo es lo que el agente está contestando.
|
||||
function antiguedad(fecha) {
|
||||
if (!fecha) return 'sin procesar'
|
||||
const dias = Math.floor((Date.now() - new Date(fecha)) / 86400000)
|
||||
if (dias <= 0) return 'hoy'
|
||||
if (dias === 1) return 'ayer'
|
||||
if (dias < 30) return `hace ${dias} días`
|
||||
const meses = Math.floor(dias / 30)
|
||||
return meses === 1 ? 'hace un mes' : `hace ${meses} meses`
|
||||
}
|
||||
|
||||
function estaVieja(d) {
|
||||
if (!d.procesado_at) return false
|
||||
return Date.now() - new Date(d.procesado_at) > 60 * 86400000
|
||||
}
|
||||
|
||||
async function eliminarDocumento(id) {
|
||||
if (!confirm('¿Eliminar esta fuente y sus fragmentos indexados?')) return
|
||||
await api.del(apiUmind(`/umind/documentos/${id}`))
|
||||
await cargarDocumentos()
|
||||
}
|
||||
|
||||
const estadoColor = computed(() => (estado) => ({
|
||||
listo: 'badge-ok',
|
||||
procesando: 'badge-alerta',
|
||||
pendiente: 'badge-neutro',
|
||||
error: 'badge-error',
|
||||
}[estado] || 'badge-neutro'))
|
||||
|
||||
// ─── Conversaciones ──────────────────────────────────────────────────────────
|
||||
const sesiones = ref([])
|
||||
async function cargarSesiones() {
|
||||
const r = await api.get(apiUmind(`/umind/sesiones?agente_id=${props.agenteId}`))
|
||||
sesiones.value = r.items || []
|
||||
}
|
||||
|
||||
// ─── Herramientas ───────────────────────────────────────────────────────────
|
||||
const tools = ref([])
|
||||
|
||||
async function cargarTools() {
|
||||
const r = await api.get(apiUmind(`/umind/tools?agente_id=${props.agenteId}`))
|
||||
tools.value = r.items || []
|
||||
}
|
||||
|
||||
// ─── Canales ────────────────────────────────────────────────────────────────
|
||||
const canales = ref([])
|
||||
|
||||
async function cargarCanales() {
|
||||
const r = await api.get(apiUmind(`/umind/canales?agente_id=${props.agenteId}`))
|
||||
canales.value = r.items || []
|
||||
}
|
||||
|
||||
// El enlace del reporte vive acá y no en la pestaña de canales: lo usa el
|
||||
// encabezado, que se ve en todas.
|
||||
const urlReporte = computed(() => {
|
||||
const hoy = new Date()
|
||||
const desde = new Date(hoy.getFullYear(), hoy.getMonth(), 1).toISOString().slice(0, 10)
|
||||
const hasta = hoy.toISOString().slice(0, 10)
|
||||
return apiUmind(`/umind/reporte.xlsx?agente_id=${props.agenteId}&desde=${desde}&hasta=${hasta}`)
|
||||
})
|
||||
|
||||
// ─── Conexiones (correo, OAuth) ────────────────────────────────────────────────
|
||||
const conexiones = ref([])
|
||||
|
||||
async function cargarConexiones() {
|
||||
const r = await api.get(apiUmind(`/umind/conexiones?agente_id=${props.agenteId}`))
|
||||
conexiones.value = r.items || []
|
||||
}
|
||||
|
||||
// Tres zonas en vez de siete pestañas. Las siete eran nuestras siete tablas
|
||||
// —documentos, tools, canales, conexiones, eventos— y un dueño de negocio no
|
||||
// piensa "voy a Conexiones": piensa "¿por qué contestó mal?" o "quiero que
|
||||
// sepa mis precios nuevos".
|
||||
//
|
||||
// Lo que se usa todos los días queda arriba; lo que se toca una vez y se
|
||||
// olvida, en Avanzado.
|
||||
const ZONAS = [
|
||||
['conversaciones', 'Conversaciones'],
|
||||
['conocimiento', 'Lo que sabe'],
|
||||
['canales', 'Dónde atiende'],
|
||||
]
|
||||
|
||||
const AVANZADO = [
|
||||
['herramientas', 'Herramientas'],
|
||||
['conexiones', 'Cuentas conectadas'],
|
||||
['auditoria', 'Problemas'],
|
||||
]
|
||||
|
||||
const mostrarAvanzado = ref(false)
|
||||
|
||||
// Si se entra directo a una pestaña avanzada (por la URL o desde el estado de
|
||||
// arriba), la sección tiene que verse abierta o el botón activo queda
|
||||
// escondido detrás de un "Avanzado" cerrado.
|
||||
if (AVANZADO.some(([k]) => k === tab.value)) mostrarAvanzado.value = true
|
||||
|
||||
function irA(destino) {
|
||||
tab.value = destino
|
||||
if (AVANZADO.some(([k]) => k === destino)) mostrarAvanzado.value = true
|
||||
}
|
||||
|
||||
// ─── Auditoría ────────────────────────────────────────────────────────────────
|
||||
const eventos = ref([])
|
||||
|
||||
async function cargarEventos() {
|
||||
const r = await api.get(apiUmind(`/umind/eventos?agente_id=${props.agenteId}`))
|
||||
eventos.value = r.items || []
|
||||
}
|
||||
|
||||
// Se observan los dos parámetros: al saltar de un agente a otro (o a un
|
||||
// agente de otro tenant) vue-router reusa la instancia y onMounted no vuelve
|
||||
// a correr, así que la pantalla quedaba con los datos del agente anterior.
|
||||
watch(
|
||||
() => [props.tenantId, props.agenteId],
|
||||
async () => {
|
||||
error.value = ''
|
||||
// Se limpia antes de pedir: si no, durante la carga se ven los datos del
|
||||
// agente anterior bajo el nombre del nuevo.
|
||||
agente.value = null
|
||||
documentos.value = []
|
||||
sesiones.value = []
|
||||
tools.value = []
|
||||
canales.value = []
|
||||
conexiones.value = []
|
||||
eventos.value = []
|
||||
try {
|
||||
await Promise.all([cargarAgente(), cargarDocumentos(), cargarSesiones(), cargarTools(), cargarCanales(), cargarConexiones(), cargarEventos()])
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<router-link :to="`/tenants/${tenantId}`" class="label hover:text-brand">← Agentes</router-link>
|
||||
|
||||
<div v-if="agente" class="mb-4 mt-1 flex items-start justify-between gap-4">
|
||||
<h1 class="text-xl font-semibold text-texto min-w-0 truncate">{{ agente.nombre }}</h1>
|
||||
<!-- Un archivo que se reenvía sirve para justificar el gasto puertas
|
||||
adentro; un panel al que hay que entrar, no. -->
|
||||
<a :href="urlReporte" class="btn-ghost shrink-0" title="Conversaciones y consumo del mes en Excel">
|
||||
<UiIcono nombre="descargar" :tam="15" /> Reporte del mes
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- El estado va antes que la navegación: es lo primero que se quiere
|
||||
saber y, además, lleva directo a lo que haya que arreglar. -->
|
||||
<EstadoAgente
|
||||
v-if="agente"
|
||||
:canales="canales"
|
||||
:documentos="documentos"
|
||||
:sesiones="sesiones"
|
||||
:eventos="eventos"
|
||||
@ir="irA"
|
||||
/>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
|
||||
|
||||
<div class="flex items-center gap-1.5 mb-6 flex-wrap">
|
||||
<button
|
||||
v-for="[key, label] in ZONAS"
|
||||
:key="key"
|
||||
class="px-3.5 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors"
|
||||
:class="tab === key
|
||||
? 'bg-brand text-white font-medium'
|
||||
: 'bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 border border-borde hover:border-brand/50'"
|
||||
@click="tab = key"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
|
||||
<!-- Lo que se configura una vez no puede competir todos los días con lo
|
||||
que se mira todos los días. -->
|
||||
<button
|
||||
class="ml-auto text-xs text-tenue hover:text-texto inline-flex items-center gap-1"
|
||||
@click="mostrarAvanzado = !mostrarAvanzado"
|
||||
>
|
||||
Avanzado
|
||||
<span class="text-[10px]">{{ mostrarAvanzado ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="mostrarAvanzado" class="flex gap-1.5 mb-6 flex-wrap">
|
||||
<button
|
||||
v-for="[key, label] in AVANZADO"
|
||||
:key="key"
|
||||
class="px-3 py-1 rounded-full text-xs whitespace-nowrap transition-colors"
|
||||
:class="tab === key
|
||||
? 'bg-texto text-white dark:bg-gray-200 dark:text-gray-900 font-medium'
|
||||
: 'bg-white dark:bg-gray-900 text-tenue border border-borde hover:border-brand/50'"
|
||||
@click="tab = key"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lo que sabe: el conocimiento y la prueba, lado a lado.
|
||||
El bucle real es leer lo que sabe → probar → corregir → probar de
|
||||
nuevo. Con las dos cosas en pestañas separadas, eso eran seis clics
|
||||
por corrección; acá es escribir y ver. -->
|
||||
<div v-if="tab === 'conocimiento'" class="grid gap-4 lg:grid-cols-[1fr_21rem] items-start">
|
||||
<div>
|
||||
<div class="card p-4 mb-4">
|
||||
<div class="flex gap-1 mb-3">
|
||||
<button
|
||||
v-for="[k, ico, l] in [['texto', 'escribir', 'Escribir'], ['archivo', 'archivo', 'Subir archivo'], ['url', 'sitio', 'Sitio web']]"
|
||||
:key="k"
|
||||
class="px-3 py-1.5 rounded-lg text-sm inline-flex items-center gap-1.5"
|
||||
:class="fuenteNueva === k ? 'bg-brand text-white font-medium' : 'text-tenue hover:text-texto'"
|
||||
@click="fuenteNueva = k"
|
||||
><UiIcono :nombre="ico" :tam="15" /> {{ l }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Lo que el dueño sabe y no está escrito en ningún lado. -->
|
||||
<form v-if="fuenteNueva === 'texto'" class="space-y-2" @submit.prevent="agregarNota">
|
||||
<input v-model="notaTitulo" placeholder="Título (ej: Horarios y zonas de entrega)" class="input" />
|
||||
<textarea
|
||||
v-model="notaTexto"
|
||||
rows="5"
|
||||
required
|
||||
placeholder="Atendemos de lunes a viernes de 9 a 18. No hacemos envíos fuera de la ciudad. La garantía es de 6 meses y cubre…"
|
||||
class="input font-normal"
|
||||
></textarea>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs text-tenue">Lo que te preguntan todos los días y no está en tu web.</p>
|
||||
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
|
||||
{{ ingestando ? 'Guardando…' : 'Guardar' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form v-else-if="fuenteNueva === 'archivo'" class="space-y-2" @submit.prevent="subirArchivo">
|
||||
<input ref="archivoRef" type="file" accept=".pdf,.docx,.txt,.md,.csv,.html,image/*" class="text-sm text-texto" />
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs text-tenue">PDF, Word, texto o una foto. Ej: tu lista de precios.</p>
|
||||
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
|
||||
{{ ingestando ? 'Leyendo…' : 'Subir' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form v-else class="space-y-2" @submit.prevent="agregarFuente">
|
||||
<div class="flex gap-2">
|
||||
<input v-model="nuevaUrl" type="url" placeholder="https://ejemplo.com" required class="input flex-1" />
|
||||
<input v-model="maxPaginas" type="number" min="1" max="200" class="input w-24" title="Máximo de páginas a leer" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center gap-2 text-xs text-tenue cursor-pointer">
|
||||
<input v-model="autoActualizar" type="checkbox" class="rounded border-borde text-brand focus:ring-brand" />
|
||||
Releer el sitio cada semana
|
||||
</label>
|
||||
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
|
||||
{{ ingestando ? 'Leyendo…' : 'Leer sitio' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card divide-y divide-borde">
|
||||
<UiEmptyState
|
||||
v-if="documentos.length === 0"
|
||||
estado="buscando"
|
||||
titulo="Todavía no sabe nada de tu negocio"
|
||||
detalle="Sin información cargada va a contestar que no sabe. Empezá por escribir tus horarios y lo que te preguntan todos los días — es lo más rápido y lo que más se nota."
|
||||
/>
|
||||
<div v-for="d in documentos" :key="d.ID" class="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-texto">{{ d.origen }}</div>
|
||||
<div class="text-xs text-tenue mt-0.5">
|
||||
<span class="px-1.5 py-0.5 rounded inline-flex items-center gap-1" :class="estadoColor(d.estado)">
|
||||
<span v-if="d.estado === 'procesando'" class="latido"></span>
|
||||
{{ d.estado }}
|
||||
</span>
|
||||
<span v-if="d.tipo"> · {{ { url: 'sitio', archivo: 'archivo', texto: 'nota' }[d.tipo] || d.tipo }}</span>
|
||||
<span v-if="d.total_chunks"> · {{ d.total_chunks }} fragmentos</span>
|
||||
<!-- La antigüedad, en rojo cuando pasó de dos meses: es lo único
|
||||
que delata que el agente contesta con información vieja. -->
|
||||
<span v-if="d.procesado_at" :class="estaVieja(d) ? 'text-amber-600 dark:text-amber-400 font-medium' : ''">
|
||||
· leído {{ antiguedad(d.procesado_at) }}
|
||||
</span>
|
||||
<span v-if="d.auto_actualizar"> · se actualiza sola</span>
|
||||
<span v-if="d.error" class="text-red-600 dark:text-red-400"> · {{ d.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
v-if="d.tipo === 'url'"
|
||||
class="text-xs text-tenue hover:text-texto"
|
||||
:title="d.auto_actualizar ? 'Dejar de releer sola' : 'Releer el sitio cada semana'"
|
||||
@click="alternarAuto(d)"
|
||||
><UiIcono :nombre="d.auto_actualizar ? 'auto' : 'refrescar'" :tam="13" class="mr-1" />{{ d.auto_actualizar ? 'auto' : 'manual' }}</button>
|
||||
<!-- Solo lo que tiene texto propio guardado: una URL se rehace
|
||||
crawleando, editarla a mano se perdería en la próxima pasada. -->
|
||||
<button
|
||||
v-if="d.tipo === 'texto' || d.tipo === 'archivo'"
|
||||
class="text-sm text-brand hover:underline"
|
||||
@click="editarDocumento(d)"
|
||||
>Editar</button>
|
||||
<button
|
||||
class="text-sm text-tenue hover:text-texto disabled:opacity-40"
|
||||
:disabled="d.estado === 'procesando'"
|
||||
:title="d.tipo === 'url' ? 'Volver a leer el sitio' : 'Rehacer los fragmentos'"
|
||||
@click="reprocesar(d)"
|
||||
>Actualizar</button>
|
||||
<button class="text-red-500 hover:text-red-700 text-sm" @click="eliminarDocumento(d.ID)">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- La prueba, al lado y siempre visible mientras se edita el
|
||||
conocimiento. En pantallas angostas cae abajo, que es el orden en
|
||||
que igual se trabaja. -->
|
||||
<div class="lg:sticky lg:top-4">
|
||||
<p class="label mb-2">Probalo</p>
|
||||
<TabChat :agente-id="agenteIdNum" />
|
||||
</div>
|
||||
|
||||
<!-- Editar una nota o el texto extraído de un archivo -->
|
||||
<div
|
||||
v-if="editandoDoc"
|
||||
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
|
||||
@click.self="editandoDoc = null"
|
||||
>
|
||||
<div class="card w-full max-w-2xl p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||
<h2 class="font-semibold text-texto mb-1">Editar fuente</h2>
|
||||
<p class="text-xs text-tenue mb-4">
|
||||
Al guardar se vuelve a leer y el agente empieza a contestar con esto.
|
||||
</p>
|
||||
|
||||
<form class="space-y-3" @submit.prevent="guardarDocumento">
|
||||
<div>
|
||||
<label class="label">Título</label>
|
||||
<input v-model="docForm.titulo" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Contenido</label>
|
||||
<textarea
|
||||
v-model="docForm.contenido"
|
||||
rows="16"
|
||||
required
|
||||
class="input font-normal leading-relaxed"
|
||||
></textarea>
|
||||
<p class="text-xs text-tenue mt-1.5">
|
||||
Si viene de una plantilla, reemplazá lo que está [entre corchetes] por tus datos.
|
||||
Lo que quede sin reemplazar el agente lo va a leer tal cual.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<button type="button" class="btn-ghost" @click="editandoDoc = null">Cancelar</button>
|
||||
<button type="submit" class="btn-primary" :disabled="guardandoDoc">
|
||||
{{ guardandoDoc ? 'Guardando…' : 'Guardar y volver a leer' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Herramientas -->
|
||||
<TabHerramientas
|
||||
v-else-if="tab === 'herramientas'"
|
||||
:agente-id="agenteIdNum"
|
||||
:tools="tools"
|
||||
@recargar="cargarTools"
|
||||
@error="(m) => (error = m)"
|
||||
/>
|
||||
|
||||
<TabCanales
|
||||
v-else-if="tab === 'canales'"
|
||||
:agente-id="agenteIdNum"
|
||||
:site-key="agente?.site_key || ''"
|
||||
:canales="canales"
|
||||
@recargar="cargarCanales"
|
||||
@error="(m) => (error = m)"
|
||||
/>
|
||||
|
||||
<TabConexiones
|
||||
v-else-if="tab === 'conexiones'"
|
||||
:agente-id="agenteIdNum"
|
||||
:conexiones="conexiones"
|
||||
@recargar="cargarConexiones"
|
||||
/>
|
||||
|
||||
<!-- Chat de prueba -->
|
||||
<TabChat v-else-if="tab === 'chat'" :agente-id="agenteIdNum" />
|
||||
|
||||
<!-- Conversaciones -->
|
||||
<TabConversaciones
|
||||
v-else-if="tab === 'conversaciones'"
|
||||
:agente-id="agenteIdNum"
|
||||
:sesiones="sesiones"
|
||||
/>
|
||||
|
||||
<!-- Auditoría -->
|
||||
<TabAuditoria v-else :eventos="eventos" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,256 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { api } from '../lib/api.js'
|
||||
import { apiUmind } from '../lib/contexto.js'
|
||||
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||
import UiIcono from '../components/ui/UiIcono.vue'
|
||||
|
||||
const props = defineProps({ id: { type: String, required: true } })
|
||||
|
||||
const items = ref([])
|
||||
const cargando = ref(true)
|
||||
const error = ref('')
|
||||
const showForm = ref(false)
|
||||
const editando = ref(null)
|
||||
const guardando = ref(false)
|
||||
const probando = ref(null)
|
||||
const resultado = ref({})
|
||||
|
||||
// Los proveedores que acepta el backend. El texto de ayuda de cada uno importa
|
||||
// más que la lista: nadie recuerda de memoria el nombre exacto de un modelo.
|
||||
const proveedores = [
|
||||
{ valor: 'openai', nombre: 'OpenAI', modelo: 'gpt-4o-mini', donde: 'platform.openai.com/api-keys' },
|
||||
{ valor: 'anthropic', nombre: 'Anthropic (Claude)', modelo: 'claude-sonnet-4-20250514', donde: 'console.anthropic.com' },
|
||||
{ valor: 'gemini', nombre: 'Google Gemini', modelo: 'gemini-2.0-flash', donde: 'aistudio.google.com/apikey' },
|
||||
{ valor: 'groq', nombre: 'Groq', modelo: 'llama-3.3-70b-versatile', donde: 'console.groq.com/keys' },
|
||||
{ valor: 'deepseek', nombre: 'DeepSeek', modelo: 'deepseek-chat', donde: 'platform.deepseek.com' },
|
||||
{ valor: 'qwen', nombre: 'Qwen (Alibaba)', modelo: 'qwen2.5-72b-instruct', donde: 'dashscope.console.aliyun.com' },
|
||||
{ valor: 'ollama', nombre: 'Ollama (tu servidor)', modelo: 'llama3.1', donde: '' },
|
||||
]
|
||||
|
||||
function vacio() {
|
||||
return { nombre: '', provider: 'openai', api_key: '', base_url: '', model_name: '' }
|
||||
}
|
||||
|
||||
const form = ref(vacio())
|
||||
|
||||
function proveedorDe(valor) {
|
||||
return proveedores.find((p) => p.valor === valor) || proveedores[0]
|
||||
}
|
||||
|
||||
async function cargar() {
|
||||
cargando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const r = await api.get(apiUmind(`/umind/ai-propia?tenant_id=${props.id}`))
|
||||
items.value = r.items || []
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
cargando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.id, cargar, { immediate: true })
|
||||
|
||||
function nueva() {
|
||||
editando.value = null
|
||||
form.value = vacio()
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function editar(c) {
|
||||
editando.value = c
|
||||
// La clave no vuelve del servidor: se deja en blanco y solo se manda si la
|
||||
// persona escribe una nueva.
|
||||
form.value = {
|
||||
nombre: c.nombre,
|
||||
provider: c.provider,
|
||||
api_key: '',
|
||||
base_url: c.base_url || '',
|
||||
model_name: c.model_name || '',
|
||||
}
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
async function guardar() {
|
||||
guardando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const cuerpo = { tenant_id: Number(props.id), ...form.value }
|
||||
if (editando.value) {
|
||||
await api.put(apiUmind(`/umind/ai-propia/${editando.value.ID}`), cuerpo)
|
||||
} else {
|
||||
await api.post(apiUmind('/umind/ai-propia'), cuerpo)
|
||||
}
|
||||
showForm.value = false
|
||||
await cargar()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
guardando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Probar contra el proveedor de verdad. Una clave vencida no se nota hasta que
|
||||
// alguien escribe: esto lo adelanta.
|
||||
async function probar(c) {
|
||||
probando.value = c.ID
|
||||
resultado.value = { ...resultado.value, [c.ID]: null }
|
||||
try {
|
||||
const r = await api.post(apiUmind(`/umind/ai-propia/${c.ID}/probar`), {})
|
||||
resultado.value = { ...resultado.value, [c.ID]: { ok: true, texto: r.respuesta } }
|
||||
} catch (e) {
|
||||
resultado.value = { ...resultado.value, [c.ID]: { ok: false, texto: e.message } }
|
||||
} finally {
|
||||
probando.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function borrar(c) {
|
||||
if (!confirm(`¿Eliminar "${c.nombre}"?`)) return
|
||||
error.value = ''
|
||||
try {
|
||||
await api.del(apiUmind(`/umind/ai-propia/${c.ID}`))
|
||||
await cargar()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<router-link :to="`/tenants/${id}`" class="text-sm text-tenue hover:text-texto inline-flex items-center gap-1.5"><UiIcono nombre="atras" :tam="14" /> Agentes</router-link>
|
||||
<h1 class="text-xl font-semibold text-texto mt-1">Tu cuenta de IA</h1>
|
||||
<p class="text-sm text-tenue mt-1 max-w-xl">
|
||||
Podés usar tu propia cuenta de OpenAI, Claude o Gemini en vez de la nuestra.
|
||||
El consumo se factura directo con el proveedor y no pasa por tu plan.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn-primary shrink-0" @click="nueva">+ Conectar cuenta</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
|
||||
|
||||
<div v-if="cargando" class="text-sm text-tenue">Cargando…</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="items.length === 0"
|
||||
class="card"
|
||||
titulo="Estás usando la IA de uMind"
|
||||
detalle="Funciona sin que hagas nada. Conectá tu propia cuenta solo si querés facturar el consumo por tu lado o usar un modelo puntual."
|
||||
>
|
||||
<button class="btn-primary" @click="nueva">Conectar mi cuenta</button>
|
||||
</UiEmptyState>
|
||||
|
||||
<div v-else class="card divide-y divide-borde">
|
||||
<div v-for="c in items" :key="c.ID" class="p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm text-texto font-medium">{{ c.nombre }}</div>
|
||||
<div class="text-xs text-tenue mt-0.5">
|
||||
{{ proveedorDe(c.provider).nombre }} · {{ c.model_name }} ·
|
||||
<span class="font-mono">{{ c.api_key_pista }}</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="resultado[c.ID]"
|
||||
class="text-xs mt-1.5"
|
||||
:class="resultado[c.ID].ok ? 'text-brand' : 'text-red-600 dark:text-red-400'"
|
||||
>
|
||||
{{ resultado[c.ID].ok ? `Responde bien — dijo "${resultado[c.ID].texto}"` : resultado[c.ID].texto }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0 text-sm">
|
||||
<button
|
||||
class="text-brand hover:underline disabled:opacity-40"
|
||||
:disabled="probando === c.ID"
|
||||
@click="probar(c)"
|
||||
>{{ probando === c.ID ? 'Probando…' : 'Probar' }}</button>
|
||||
<button class="text-tenue hover:text-texto" @click="editar(c)">Editar</button>
|
||||
<button class="text-red-500 hover:text-red-700" @click="borrar(c)">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="items.length" class="text-xs text-tenue mt-3">
|
||||
Para que un agente la use, elegila en su campo <strong>Config de IA</strong>.
|
||||
</p>
|
||||
|
||||
<!-- Alta / edición -->
|
||||
<div
|
||||
v-if="showForm"
|
||||
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
|
||||
@click.self="showForm = false"
|
||||
>
|
||||
<div class="card w-full max-w-lg p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||
<h2 class="font-semibold text-texto mb-4">
|
||||
{{ editando ? 'Editar cuenta de IA' : 'Conectar tu cuenta de IA' }}
|
||||
</h2>
|
||||
|
||||
<form class="space-y-3" @submit.prevent="guardar">
|
||||
<div>
|
||||
<label class="label">Nombre</label>
|
||||
<input v-model="form.nombre" required placeholder="ej: Mi cuenta de OpenAI" class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Proveedor</label>
|
||||
<select v-model="form.provider" class="input">
|
||||
<option v-for="p in proveedores" :key="p.valor" :value="p.valor">{{ p.nombre }}</option>
|
||||
</select>
|
||||
<p v-if="proveedorDe(form.provider).donde" class="text-xs text-tenue mt-1">
|
||||
La clave se saca de <span class="font-mono">{{ proveedorDe(form.provider).donde }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Modelo</label>
|
||||
<input
|
||||
v-model="form.model_name"
|
||||
required
|
||||
:placeholder="`ej: ${proveedorDe(form.provider).modelo}`"
|
||||
class="input font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="form.provider !== 'ollama'">
|
||||
<label class="label">
|
||||
Clave de API
|
||||
<span v-if="editando" class="text-tenue font-normal">— dejala vacía para no cambiarla</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.api_key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:required="!editando"
|
||||
placeholder="sk-..."
|
||||
class="input font-mono"
|
||||
/>
|
||||
<p class="text-xs text-tenue mt-1">Se guarda cifrada y no se vuelve a mostrar.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.provider === 'ollama'">
|
||||
<label class="label">URL de tu servidor</label>
|
||||
<input
|
||||
v-model="form.base_url"
|
||||
required
|
||||
placeholder="https://ollama.tudominio.com/v1"
|
||||
class="input font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" class="btn-ghost" @click="showForm = false">Cancelar</button>
|
||||
<button type="submit" class="btn-primary" :disabled="guardando">
|
||||
{{ guardando ? 'Guardando…' : 'Guardar' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
import { apiUmind, contexto } from '../lib/contexto.js'
|
||||
import UiMascota from '../components/ui/UiMascota.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const cargando = ref(contexto.esPortal)
|
||||
const sinEspacios = ref(false)
|
||||
|
||||
// El cliente no tiene "tenants": tiene SU asistente. Si solo hay un espacio
|
||||
// con un agente —el caso normal— entrar obligándolo a elegir dos veces es
|
||||
// jerarquía nuestra, no suya. Se salta directo al agente.
|
||||
//
|
||||
// Con varios espacios o varios agentes sí hace falta elegir, así que ahí se
|
||||
// muestra la pantalla de siempre.
|
||||
async function irAlAgenteSiEsUnoSolo() {
|
||||
if (!contexto.esPortal) return
|
||||
try {
|
||||
const t = await api.get(apiUmind('/umind/tenants'))
|
||||
const tenants = t.items || []
|
||||
if (tenants.length === 0) {
|
||||
sinEspacios.value = true
|
||||
return
|
||||
}
|
||||
if (tenants.length !== 1) return
|
||||
|
||||
const tenantId = tenants[0].ID
|
||||
const a = await api.get(apiUmind(`/umind/agentes?tenant_id=${tenantId}`))
|
||||
const agentes = a.items || []
|
||||
if (agentes.length === 1) {
|
||||
router.replace(`/tenants/${tenantId}/agentes/${agentes[0].ID}`)
|
||||
return
|
||||
}
|
||||
router.replace(`/tenants/${tenantId}`)
|
||||
} catch {
|
||||
// Si algo falla se queda en esta pantalla, que igual deja navegar.
|
||||
} finally {
|
||||
cargando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(irAlAgenteSiEsUnoSolo)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="cargando" class="flex flex-col items-center justify-center py-24 text-sm text-tenue">
|
||||
Abriendo tu asistente…
|
||||
</div>
|
||||
|
||||
<!-- Sin espacios no hay nada que elegir ni forma de crearlo: mandarlo a
|
||||
"elegí de la izquierda" cuando la izquierda está vacía es un callejón. -->
|
||||
<div v-else-if="sinEspacios" class="max-w-md mx-auto text-center py-20">
|
||||
<UiMascota estado="durmiendo" :tam="80" class="mx-auto mb-4 text-brand" />
|
||||
<h1 class="text-lg font-medium text-texto">Todavía no tenés un asistente activo</h1>
|
||||
<p class="text-sm text-tenue mt-2">
|
||||
uMind contesta por vos en WhatsApp y en tu sitio, con la información de tu negocio.
|
||||
Entiende las notas de voz y lee las fotos y archivos que te mandan tus clientes.
|
||||
</p>
|
||||
<a
|
||||
class="btn-primary inline-block mt-5"
|
||||
href="mailto:soporte@u-site.app?subject=Quiero%20activar%20uMind"
|
||||
>Quiero activarlo</a>
|
||||
<p class="text-xs text-tenue mt-4">
|
||||
<a href="/portal/dashboard" class="underline">Volver al portal</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center text-center py-24">
|
||||
<UiMascota estado="normal" :tam="64" class="mx-auto mb-4 text-brand" />
|
||||
<h1 class="text-lg font-medium text-texto">
|
||||
{{ contexto.esPortal ? 'Elegí tu espacio de la izquierda' : 'Elegí un espacio de la izquierda' }}
|
||||
</h1>
|
||||
<p class="text-sm text-tenue mt-1">
|
||||
{{ contexto.esPortal
|
||||
? 'Adentro vas a poder crear y configurar tus agentes.'
|
||||
: 'o creá uno nuevo para empezar a configurar su agente.' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,340 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
import { apiUmind, contexto } from '../lib/contexto.js'
|
||||
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||
import UiIcono from '../components/ui/UiIcono.vue'
|
||||
|
||||
const props = defineProps({ id: { type: String, required: true } })
|
||||
const tenantId = computed(() => Number(props.id))
|
||||
const router = useRouter()
|
||||
|
||||
const tenant = ref(null)
|
||||
const agentes = ref([])
|
||||
const aiConfigs = ref([])
|
||||
const error = ref('')
|
||||
const showForm = ref(false)
|
||||
const editing = ref(null)
|
||||
const form = ref(vacio())
|
||||
|
||||
const plan = ref(null)
|
||||
const resumen = ref({})
|
||||
const cargando = ref(true)
|
||||
|
||||
// Un agente sin fuentes de conocimiento responde de memoria e inventa datos
|
||||
// —es exactamente el problema de la URL falsa— así que eso se avisa acá y no
|
||||
// recién cuando un cliente recibe una respuesta inventada.
|
||||
function estadoDe(a) {
|
||||
const r = resumen.value[a.ID] || {}
|
||||
if (!a.activo) return { tipo: 'neutro', texto: 'inactivo' }
|
||||
if (!(r.documentos > 0)) return { tipo: 'alerta', texto: 'sin conocimiento' }
|
||||
return { tipo: 'ok', texto: 'listo' }
|
||||
}
|
||||
|
||||
function datosDe(a) {
|
||||
const r = resumen.value[a.ID] || {}
|
||||
return {
|
||||
documentos: r.documentos || 0,
|
||||
canales: r.canales || 0,
|
||||
conversaciones: r.conversaciones_7d || 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Iniciales para el avatar; el color de marca del agente lo tiñe.
|
||||
function iniciales(nombre) {
|
||||
return String(nombre || '?').trim().split(/\s+/).slice(0, 2).map(p => p[0]).join('').toUpperCase()
|
||||
}
|
||||
|
||||
// max_agentes 0 = ilimitado. Sin plan asignado tampoco hay límite: es
|
||||
// deliberado (los tenants que existían antes de los planes no se rompen),
|
||||
// pero hay que decirlo, o parece que el límite está fallando.
|
||||
const cupo = computed(() => {
|
||||
if (!plan.value) return { sinPlan: true }
|
||||
const max = plan.value.max_agentes || 0
|
||||
return {
|
||||
sinPlan: false,
|
||||
nombre: plan.value.nombre,
|
||||
ilimitado: max <= 0,
|
||||
max,
|
||||
usados: agentes.value.length,
|
||||
lleno: max > 0 && agentes.value.length >= max,
|
||||
}
|
||||
})
|
||||
|
||||
function vacio() {
|
||||
return { nombre: '', ai_config_id: null, tono: '', mensaje_bienvenida: '', color: '#8eb02f', activo: true, plantilla_rubro: '' }
|
||||
}
|
||||
|
||||
// Puntos de partida por rubro. Un agente vacío no sirve el primer día, y
|
||||
// escribir el conocimiento desde cero frente a un campo en blanco es donde la
|
||||
// mayoría abandona.
|
||||
const plantillas = ref([])
|
||||
|
||||
async function cargar() {
|
||||
error.value = ''
|
||||
cargando.value = true
|
||||
try {
|
||||
const [t, a, ai, pl] = await Promise.all([
|
||||
api.get(apiUmind('/umind/tenants')),
|
||||
api.get(apiUmind(`/umind/agentes?tenant_id=${props.id}`)),
|
||||
api.get(apiUmind('/umind/ai-configs')),
|
||||
api.get(apiUmind('/umind/plantillas-rubro')),
|
||||
])
|
||||
tenant.value = (t.items || []).find((x) => String(x.ID) === props.id) || null
|
||||
agentes.value = a.items || []
|
||||
plan.value = a.plan || null
|
||||
resumen.value = a.resumen || {}
|
||||
aiConfigs.value = ai.items || []
|
||||
plantillas.value = pl.items || []
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
cargando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Un agente que ya funciona es la mejor plantilla del siguiente: el catálogo de
|
||||
// rubros da un arranque genérico, esto copia uno real con su conocimiento.
|
||||
async function duplicarAgente(a) {
|
||||
const nombre = prompt(`Nombre de la copia de "${a.nombre}":`, `${a.nombre} (copia)`)
|
||||
if (nombre === null) return
|
||||
error.value = ''
|
||||
try {
|
||||
const r = await api.post(apiUmind(`/umind/agentes/${a.ID}/duplicar`), {
|
||||
tenant_id: Number(tenantId.value),
|
||||
nombre: nombre.trim(),
|
||||
})
|
||||
await cargar()
|
||||
if (r.aviso) alert(r.aviso)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function nuevoAgente() {
|
||||
editing.value = null
|
||||
form.value = vacio()
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function editarAgente(a) {
|
||||
editing.value = a
|
||||
form.value = {
|
||||
nombre: a.nombre,
|
||||
ai_config_id: a.ai_config_id,
|
||||
tono: a.tono,
|
||||
mensaje_bienvenida: a.mensaje_bienvenida,
|
||||
color: a.color || '#8eb02f',
|
||||
activo: a.activo,
|
||||
}
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
async function guardar() {
|
||||
try {
|
||||
if (editing.value) {
|
||||
await api.put(apiUmind(`/umind/agentes/${editing.value.ID}`), { tenant_id: tenantId.value, ...form.value })
|
||||
showForm.value = false
|
||||
await cargar()
|
||||
} else {
|
||||
const r = await api.post(apiUmind('/umind/agentes'), { tenant_id: tenantId.value, ...form.value })
|
||||
showForm.value = false
|
||||
router.push(`/tenants/${tenantId.value}/agentes/${r.id}`)
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function eliminarAgente(a) {
|
||||
if (!confirm(`¿Eliminar el agente "${a.nombre}"? Esto no se puede deshacer.`)) return
|
||||
await api.del(apiUmind(`/umind/agentes/${a.ID}`))
|
||||
await cargar()
|
||||
}
|
||||
|
||||
// watch con immediate en vez de onMounted: al navegar entre /tenants/2 y
|
||||
// /tenants/3 vue-router reusa la misma instancia del componente, así que
|
||||
// onMounted no vuelve a dispararse y la pantalla seguía mostrando los agentes
|
||||
// del tenant anterior.
|
||||
watch(() => props.id, cargar, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="tenant" class="mb-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-texto">{{ tenant.nombre }}</h1>
|
||||
<p class="text-xs text-tenue mt-1">{{ tenant.dominios_permitidos || 'sin dominios configurados' }}</p>
|
||||
</div>
|
||||
<router-link :to="`/tenants/${tenantId ?? id}/ia`" class="btn-ghost">Tu IA</router-link>
|
||||
<router-link :to="`/tenants/${tenantId ?? id}/uso`" class="btn-ghost inline-flex items-center gap-1.5"><UiIcono nombre="grafico" /> Consumo</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-sm font-medium text-tenue">Agentes</h2>
|
||||
<span v-if="cupo.sinPlan" class="badge-alerta">sin plan · sin límite</span>
|
||||
<span v-else-if="cupo.ilimitado" class="badge-neutro">{{ cupo.nombre }} · ilimitado</span>
|
||||
<span v-else :class="cupo.lleno ? 'badge-alerta' : 'badge-neutro'">
|
||||
{{ cupo.nombre }} · {{ cupo.usados }} de {{ cupo.max }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn-primary" :disabled="cupo.lleno" @click="nuevoAgente">+ Nuevo agente</button>
|
||||
</div>
|
||||
<p v-if="cupo.sinPlan && !contexto.esPortal" class="text-xs text-tenue -mt-2 mb-4">
|
||||
Este espacio no tiene plan asignado, así que no se le aplica ningún límite de agentes. Asignale uno desde el lápiz del espacio, en la barra izquierda.
|
||||
</p>
|
||||
<p v-else-if="cupo.lleno" class="text-xs text-tenue -mt-2 mb-4">
|
||||
Alcanzaste el máximo de agentes de tu plan.
|
||||
</p>
|
||||
|
||||
<!-- Esqueleto: la carga hace 3 llamadas, y sin esto la pantalla queda en
|
||||
blanco el tiempo suficiente como para parecer rota. -->
|
||||
<div v-if="cargando" class="grid gap-3 sm:grid-cols-2">
|
||||
<div v-for="n in 2" :key="n" class="card p-4 animate-pulse">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-elevado"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-3.5 w-28 rounded bg-elevado"></div>
|
||||
<div class="h-2.5 w-16 rounded bg-elevado mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-2.5 w-full rounded bg-elevado mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="agentes.length === 0"
|
||||
class="card"
|
||||
estado="durmiendo"
|
||||
titulo="Todavía no hay agentes"
|
||||
detalle="Creá el primero y cargale su base de conocimiento para que empiece a responder."
|
||||
>
|
||||
<button class="btn-primary" :disabled="cupo.lleno" @click="nuevoAgente">+ Crear el primer agente</button>
|
||||
</UiEmptyState>
|
||||
|
||||
<div v-else class="grid gap-3 sm:grid-cols-2">
|
||||
<router-link
|
||||
v-for="a in agentes"
|
||||
:key="a.ID"
|
||||
:to="`/tenants/${tenantId}/agentes/${a.ID}`"
|
||||
class="card p-4 relative group hover:shadow-lg hover:-translate-y-0.5 transition-all overflow-hidden"
|
||||
>
|
||||
<!-- Franja con el color de marca del agente: el mismo que ve el
|
||||
visitante en el widget, así la tarjeta se reconoce de un vistazo. -->
|
||||
<span class="absolute inset-x-0 top-0 h-1" :style="{ background: a.color || '#8eb02f' }"></span>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<span
|
||||
class="w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-semibold shrink-0"
|
||||
:style="{ background: a.color || '#8eb02f', opacity: a.activo ? 1 : 0.4 }"
|
||||
>{{ iniciales(a.nombre) }}</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-texto truncate">{{ a.nombre }}</span>
|
||||
<span :class="`badge-${estadoDe(a).tipo}`">{{ estadoDe(a).texto }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-tenue mt-0.5 truncate">{{ a.tono || 'sin tono definido' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<button class="p-1 text-tenue hover:text-texto" title="Editar" @click.prevent.stop="editarAgente(a)"><UiIcono nombre="lapiz" :tam="14" /></button>
|
||||
<button
|
||||
class="p-1 text-tenue hover:text-texto"
|
||||
title="Duplicar: copia el conocimiento y las herramientas a un agente nuevo"
|
||||
@click.prevent.stop="duplicarAgente(a)"
|
||||
><UiIcono nombre="copiar" :tam="14" /></button>
|
||||
<button class="p-1 text-tenue hover:text-red-600" title="Eliminar" @click.prevent.stop="eliminarAgente(a)"><UiIcono nombre="cerrar" :tam="14" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 mt-3.5 pt-3 border-t border-borde text-xs text-tenue">
|
||||
<span class="tabular-nums"><b class="text-texto font-medium">{{ datosDe(a).conversaciones }}</b> conversaciones · 7d</span>
|
||||
<span class="tabular-nums"><b class="text-texto font-medium">{{ datosDe(a).documentos }}</b> fuentes</span>
|
||||
<span class="tabular-nums"><b class="text-texto font-medium">{{ datosDe(a).canales }}</b> canales</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="showForm" class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" @click.self="showForm = false">
|
||||
<!-- max-h + scroll propio: con las opciones de plantilla el formulario
|
||||
pasa de largo la pantalla, y sin esto el botón de guardar queda
|
||||
abajo del borde inferior, inalcanzable. -->
|
||||
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 w-full max-w-lg max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">{{ editing ? 'Editar agente' : 'Nuevo agente' }}</h2>
|
||||
<form class="space-y-3" @submit.prevent="guardar">
|
||||
<div>
|
||||
<label class="label">Nombre</label>
|
||||
<input v-model="form.nombre" required placeholder="ej: Ventas, Soporte" class="input" />
|
||||
</div>
|
||||
|
||||
<!-- Solo al crear: en un agente que ya existe, precargar notas
|
||||
pisaría el conocimiento que el dueño ya escribió. -->
|
||||
<div v-if="!editing && plantillas.length">
|
||||
<label class="label">Arrancar con</label>
|
||||
<div class="grid gap-1.5">
|
||||
<label
|
||||
v-for="p in [{ clave: '', nombre: 'Agente en blanco', descripcion: 'Sin conocimiento cargado. Lo escribís vos desde cero.' }, ...plantillas]"
|
||||
:key="p.clave"
|
||||
class="flex gap-2.5 p-2.5 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.plantilla_rubro === p.clave
|
||||
? 'border-brand bg-brand/5'
|
||||
: 'border-borde hover:border-brand/40'"
|
||||
>
|
||||
<input v-model="form.plantilla_rubro" type="radio" :value="p.clave" class="mt-1 text-brand focus:ring-brand" />
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm text-texto">{{ p.nombre }}</span>
|
||||
<span class="block text-xs text-tenue">{{ p.descripcion }}</span>
|
||||
<span v-if="p.notas" class="block text-xs text-tenue mt-0.5">
|
||||
{{ p.notas }} notas listas para editar · {{ p.resumen }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-tenue mt-1.5">
|
||||
Las notas vienen con ejemplos entre corchetes — abrilas y reemplazalas por tus datos reales.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Config de IA</label>
|
||||
<select v-model="form.ai_config_id" class="input">
|
||||
<option :value="null">— sin asignar —</option>
|
||||
<option v-for="c in aiConfigs" :key="c.ID" :value="c.ID">{{ c.nombre }} ({{ c.provider }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Tono / personalidad</label>
|
||||
<textarea v-model="form.tono" rows="2" class="input"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Mensaje de bienvenida</label>
|
||||
<input v-model="form.mensaje_bienvenida" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Color del widget</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="form.color" type="color" class="w-10 h-9 border border-gray-300 dark:border-gray-700 rounded cursor-pointer bg-white dark:bg-gray-800" />
|
||||
<input v-model="form.color" type="text" pattern="#[0-9a-fA-F]{6}" class="flex-1 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<input v-model="form.activo" type="checkbox" />
|
||||
Activo
|
||||
</label>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400" @click="showForm = false">Cancelar</button>
|
||||
<button type="submit" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg">
|
||||
{{ editing ? 'Guardar' : 'Crear' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,206 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { api } from '../lib/api.js'
|
||||
import { apiUmind } from '../lib/contexto.js'
|
||||
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||
import UiIcono from '../components/ui/UiIcono.vue'
|
||||
|
||||
const props = defineProps({ id: { type: String, required: true } })
|
||||
|
||||
const cargando = ref(true)
|
||||
const error = ref('')
|
||||
const datos = ref(null)
|
||||
|
||||
// Por defecto, el mes en curso.
|
||||
const hoy = new Date()
|
||||
const desde = ref(new Date(hoy.getFullYear(), hoy.getMonth(), 1).toISOString().slice(0, 10))
|
||||
const hasta = ref(hoy.toISOString().slice(0, 10))
|
||||
|
||||
const ETIQUETAS = {
|
||||
ia: { nombre: 'Inteligencia artificial', icono: 'cerebro', color: '#8eb02f' },
|
||||
ocr: { nombre: 'Lectura de imágenes', icono: 'imagen', color: '#2f7fb0' },
|
||||
whisper: { nombre: 'Transcripción de audio', icono: 'microfono', color: '#b0752f' },
|
||||
}
|
||||
const etiqueta = (t) => ETIQUETAS[t] || { nombre: t, icono: 'grafico', color: '#94a3b8' }
|
||||
|
||||
async function cargar() {
|
||||
cargando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
datos.value = await api.get(
|
||||
apiUmind(`/umind/uso?tenant_id=${props.id}&desde=${desde.value}&hasta=${hasta.value}`),
|
||||
)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
cargando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const moneda = computed(() => datos.value?.plan?.moneda || 'COP')
|
||||
const resumen = computed(() => datos.value?.resumen || [])
|
||||
const totalPeriodo = computed(() => datos.value?.total_periodo || 0)
|
||||
const tope = computed(() => datos.value?.plan?.tope_consumo_mensual || 0)
|
||||
|
||||
const porcentajeTope = computed(() => {
|
||||
if (!tope.value) return 0
|
||||
return Math.min(100, (totalPeriodo.value / tope.value) * 100)
|
||||
})
|
||||
|
||||
function money(v) {
|
||||
return `${moneda.value} ${Number(v || 0).toLocaleString('es-CO', { maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function numero(v) {
|
||||
return Number(v || 0).toLocaleString('es-CO', { maximumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
// Consumo por día, para el gráfico. Se arma desde el detalle en vez de pedirle
|
||||
// otro agregado al backend: son a lo sumo 1000 filas y evita un endpoint más.
|
||||
const porDia = computed(() => {
|
||||
const mapa = new Map()
|
||||
for (const u of datos.value?.detalle || []) {
|
||||
const dia = String(u.CreatedAt).slice(0, 10)
|
||||
mapa.set(dia, (mapa.get(dia) || 0) + (u.costo || 0))
|
||||
}
|
||||
const dias = [...mapa.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
||||
const max = Math.max(...dias.map((d) => d[1]), 0)
|
||||
return { dias, max }
|
||||
})
|
||||
|
||||
function atajo(dias) {
|
||||
const fin = new Date()
|
||||
const ini = new Date()
|
||||
ini.setDate(ini.getDate() - dias)
|
||||
desde.value = ini.toISOString().slice(0, 10)
|
||||
hasta.value = fin.toISOString().slice(0, 10)
|
||||
cargar()
|
||||
}
|
||||
|
||||
// Ver el comentario en TenantAgentes.vue: la instancia se reusa al cambiar
|
||||
// de tenant, así que hay que reaccionar al parámetro y no al montaje.
|
||||
watch(() => props.id, cargar, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-texto">Consumo</h1>
|
||||
<p class="text-sm text-tenue mt-0.5">Lo que usaron tus agentes y cuánto cuesta.</p>
|
||||
</div>
|
||||
<router-link :to="`/tenants/${id}`" class="btn-ghost inline-flex items-center gap-1.5"><UiIcono nombre="atras" :tam="15" /> Agentes</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="card p-4 mb-4">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label class="label">Desde</label>
|
||||
<input v-model="desde" type="date" class="input !w-auto" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Hasta</label>
|
||||
<input v-model="hasta" type="date" class="input !w-auto" />
|
||||
</div>
|
||||
<button class="btn-primary" @click="cargar">Aplicar</button>
|
||||
<div class="flex gap-1 ml-auto">
|
||||
<button class="btn-ghost !px-2.5 !py-1 !text-xs" @click="atajo(6)">7 días</button>
|
||||
<button class="btn-ghost !px-2.5 !py-1 !text-xs" @click="atajo(29)">30 días</button>
|
||||
<button class="btn-ghost !px-2.5 !py-1 !text-xs" @click="atajo(89)">90 días</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
|
||||
<p v-if="cargando" class="text-sm text-tenue">Cargando…</p>
|
||||
|
||||
<template v-else-if="datos">
|
||||
<!-- Totales -->
|
||||
<div class="grid gap-3 sm:grid-cols-3 mb-4">
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-tenue">Consumo del período</p>
|
||||
<p class="text-2xl font-semibold text-texto mt-1 tabular-nums">{{ money(totalPeriodo) }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-tenue">Pendiente de facturar</p>
|
||||
<p class="text-2xl font-semibold text-texto mt-1 tabular-nums">
|
||||
{{ money(datos.pendiente_facturar) }}
|
||||
</p>
|
||||
<p class="text-[11px] text-tenue mt-0.5">Se suma a tu próxima factura.</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-tenue">Plan</p>
|
||||
<p class="text-lg font-semibold text-texto mt-1">{{ datos.plan?.nombre || 'Sin plan' }}</p>
|
||||
<p v-if="datos.plan" class="text-[11px] text-tenue mt-0.5">
|
||||
Mensualidad {{ money(datos.plan.precio_mensual) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tope -->
|
||||
<div v-if="tope" class="card p-4 mb-4">
|
||||
<div class="flex items-center justify-between text-sm mb-2">
|
||||
<span class="text-texto">Tope mensual</span>
|
||||
<span class="text-tenue tabular-nums">{{ money(totalPeriodo) }} / {{ money(tope) }}</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-elevado overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="porcentajeTope >= 100 ? 'bg-red-500' : porcentajeTope >= 80 ? 'bg-amber-500' : 'bg-brand'"
|
||||
:style="{ width: porcentajeTope + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<p v-if="porcentajeTope >= 100" class="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
||||
Superaste el tope de tu plan. El servicio sigue funcionando y el excedente se cobra en el próximo ciclo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Desglose por tipo -->
|
||||
<div class="card divide-y divide-borde mb-4">
|
||||
<div class="px-4 py-3">
|
||||
<h2 class="text-sm font-medium text-texto">Desglose</h2>
|
||||
</div>
|
||||
<UiEmptyState
|
||||
v-if="resumen.length === 0"
|
||||
estado="durmiendo"
|
||||
titulo="Sin consumo en este período"
|
||||
detalle="Cuando tus agentes respondan mensajes, lean imágenes o transcriban audios, vas a verlo acá."
|
||||
/>
|
||||
<div v-for="r in resumen" :key="r.tipo" class="px-4 py-3 flex items-center gap-3">
|
||||
<UiIcono :nombre="etiqueta(r.tipo).icono" :tam="18" class="text-tenue" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-texto">{{ etiqueta(r.tipo).nombre }}</p>
|
||||
<p class="text-xs text-tenue tabular-nums">
|
||||
{{ numero(r.cantidad) }} {{ r.unidad }} · {{ numero(r.eventos) }} usos
|
||||
</p>
|
||||
</div>
|
||||
<!-- Costo cero con consumo real quiere decir cuenta propia: lo
|
||||
paga su proveedor. Mostrar "$0" a secas parecería un error. -->
|
||||
<span v-if="r.costo > 0" class="text-sm font-medium text-texto tabular-nums">{{ money(r.costo) }}</span>
|
||||
<span v-else class="text-xs text-tenue text-right leading-tight">
|
||||
va por tu<br />cuenta de IA
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Consumo por día: SVG a mano, no vale una librería de charts por esto -->
|
||||
<div v-if="porDia.dias.length" class="card p-4">
|
||||
<h2 class="text-sm font-medium text-texto mb-3">Por día</h2>
|
||||
<div class="flex items-end gap-1 h-28">
|
||||
<div
|
||||
v-for="[dia, costo] in porDia.dias"
|
||||
:key="dia"
|
||||
class="flex-1 min-w-[3px] bg-brand/70 hover:bg-brand rounded-t transition-colors"
|
||||
:style="{ height: porDia.max ? Math.max(2, (costo / porDia.max) * 100) + '%' : '2px' }"
|
||||
:title="`${dia}: ${money(costo)}`"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-[11px] text-tenue mt-2">
|
||||
<span>{{ porDia.dias[0][0] }}</span>
|
||||
<span>{{ porDia.dias[porDia.dias.length - 1][0] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import UiIcono from '../../components/ui/UiIcono.vue'
|
||||
|
||||
// Responde "¿está bien mi asistente?" sin hacer un solo clic.
|
||||
//
|
||||
// Antes había que entrar pestaña por pestaña para saberlo: si estaba atendiendo
|
||||
// en algún lado, si sabía algo, si alguien le había escrito. Tres datos que el
|
||||
// dueño quiere de un vistazo y que estaban repartidos en tres pantallas.
|
||||
const props = defineProps({
|
||||
canales: { type: Array, default: () => [] },
|
||||
documentos: { type: Array, default: () => [] },
|
||||
sesiones: { type: Array, default: () => [] },
|
||||
eventos: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['ir'])
|
||||
|
||||
const canalesActivos = computed(() => props.canales.filter((c) => c.activo))
|
||||
|
||||
// Los tipos vienen en minúscula del backend; "Atendiendo en whatsapp" se lee
|
||||
// como un error de tipeo.
|
||||
const NOMBRES_CANAL = { whatsapp: 'WhatsApp', telegram: 'Telegram', web: 'tu web' }
|
||||
const nombreCanal = (t) => NOMBRES_CANAL[t] || t
|
||||
|
||||
const fuentesListas = computed(() => props.documentos.filter((d) => d.estado === 'listo'))
|
||||
|
||||
// Dos meses es cuando una fuente empieza a ser sospechosa de estar vieja: es el
|
||||
// mismo corte que usa la lista de conocimiento, para que no digan cosas
|
||||
// distintas.
|
||||
const fuentesViejas = computed(() =>
|
||||
fuentesListas.value.filter(
|
||||
(d) => d.procesado_at && Date.now() - new Date(d.procesado_at) > 60 * 86400000,
|
||||
),
|
||||
)
|
||||
|
||||
const problemas = computed(() => props.eventos.filter((e) => e.nivel === 'error').length)
|
||||
|
||||
// Cada señal dice qué pasa y, cuando algo anda mal, a dónde ir a arreglarlo.
|
||||
const señales = computed(() => [
|
||||
{
|
||||
clave: 'canales',
|
||||
icono: 'mensaje',
|
||||
bien: canalesActivos.value.length > 0,
|
||||
texto: canalesActivos.value.length
|
||||
? `Atendiendo en ${canalesActivos.value.map((c) => nombreCanal(c.tipo)).join(' y ')}`
|
||||
: 'No está atendiendo en ningún lado',
|
||||
destino: 'canales',
|
||||
},
|
||||
{
|
||||
clave: 'conocimiento',
|
||||
icono: 'escribir',
|
||||
bien: fuentesListas.value.length > 0 && fuentesViejas.value.length === 0,
|
||||
texto: !fuentesListas.value.length
|
||||
? 'Todavía no sabe nada de tu negocio'
|
||||
: fuentesViejas.value.length
|
||||
? `${fuentesViejas.value.length} de ${fuentesListas.value.length} fuentes están viejas`
|
||||
: `Sabe de ${fuentesListas.value.length} ${fuentesListas.value.length === 1 ? 'cosa' : 'cosas'}`,
|
||||
destino: 'conocimiento',
|
||||
},
|
||||
{
|
||||
clave: 'conversaciones',
|
||||
icono: 'grafico',
|
||||
// Que nadie haya escrito todavía no es un problema: es un dato.
|
||||
bien: true,
|
||||
neutro: props.sesiones.length === 0,
|
||||
texto: props.sesiones.length
|
||||
? `${props.sesiones.length} ${props.sesiones.length === 1 ? 'conversación' : 'conversaciones'}`
|
||||
: 'Nadie escribió todavía',
|
||||
destino: 'conversaciones',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card p-1 mb-6 flex flex-wrap">
|
||||
<button
|
||||
v-for="s in señales"
|
||||
:key="s.clave"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-elevado transition-colors text-left"
|
||||
@click="emit('ir', s.destino)"
|
||||
>
|
||||
<UiIcono
|
||||
:nombre="s.bien ? s.icono : 'alerta'"
|
||||
:tam="16"
|
||||
:class="s.neutro ? 'text-tenue' : s.bien ? 'text-brand' : 'text-amber-600 dark:text-amber-400'"
|
||||
/>
|
||||
<span :class="s.neutro ? 'text-tenue' : s.bien ? 'text-texto' : 'text-amber-700 dark:text-amber-400 font-medium'">
|
||||
{{ s.texto }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="problemas"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-elevado transition-colors ml-auto"
|
||||
@click="emit('ir', 'auditoria')"
|
||||
>
|
||||
<UiIcono nombre="alerta" :tam="16" class="text-red-600 dark:text-red-400" />
|
||||
<span class="text-red-700 dark:text-red-400 font-medium">
|
||||
{{ problemas }} {{ problemas === 1 ? 'problema' : 'problemas' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||
|
||||
// Los eventos llegan del padre, que ya los carga junto con el resto del
|
||||
// agente: pedirlos otra vez acá duplicaría la llamada cada vez que alguien
|
||||
// entra a la pestaña.
|
||||
defineProps({
|
||||
eventos: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const nivelColor = computed(() => (nivel) => ({
|
||||
error: 'badge-error',
|
||||
warn: 'badge-alerta',
|
||||
}[nivel] || 'badge-neutro'))
|
||||
|
||||
function formatearFecha(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString()
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p class="label mb-4">
|
||||
Errores y eventos técnicos de este agente — fallos al llamar a la IA, a una herramienta, al correo o a los canales. Últimos 100.
|
||||
</p>
|
||||
<div class="card divide-y divide-borde max-h-[32rem] overflow-y-auto">
|
||||
<UiEmptyState
|
||||
v-if="eventos.length === 0"
|
||||
estado="contenta"
|
||||
titulo="Ningún problema registrado"
|
||||
detalle="Acá aparecen los errores: una herramienta que no responde, una fuente que no se pudo leer. Que esté vacío es buena señal."
|
||||
/>
|
||||
<details v-for="e in eventos" :key="e.ID" class="p-3">
|
||||
<summary class="cursor-pointer flex items-center gap-2 text-sm">
|
||||
<span class="px-1.5 py-0.5 rounded text-xs shrink-0" :class="nivelColor(e.nivel)">{{ e.nivel }}</span>
|
||||
<span class="text-tenue text-xs shrink-0">{{ e.origen }}</span>
|
||||
<span class="text-texto truncate">{{ e.mensaje }}</span>
|
||||
<span class="text-tenue text-xs ml-auto shrink-0">{{ formatearFecha(e.CreatedAt) }}</span>
|
||||
</summary>
|
||||
<pre v-if="e.detalle" class="mt-2 bg-elevado border border-borde rounded-lg p-2 text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap">{{ e.detalle }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,232 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { api } from '../../lib/api.js'
|
||||
import { apiUmind } from '../../lib/contexto.js'
|
||||
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||
import UiIcono from '../../components/ui/UiIcono.vue'
|
||||
|
||||
const props = defineProps({
|
||||
agenteId: { type: Number, required: true },
|
||||
siteKey: { type: String, default: '' },
|
||||
canales: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['recargar', 'error'])
|
||||
|
||||
const showCanalForm = ref(false)
|
||||
const canalForm = ref(canalVacio())
|
||||
const widgetCopiado = ref(false)
|
||||
|
||||
// El reporte se descarga con una navegación normal (no fetch): así el
|
||||
// navegador maneja el archivo y la cookie de sesión viaja sola.
|
||||
const widgetSnippet = computed(() => {
|
||||
const siteKey = props.siteKey || 'TU_SITE_KEY'
|
||||
return `<script src="${window.location.origin}/widget/umind.js" data-site="${siteKey}" defer><\/script>`
|
||||
})
|
||||
|
||||
async function copiarWidget() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(widgetSnippet.value)
|
||||
widgetCopiado.value = true
|
||||
setTimeout(() => (widgetCopiado.value = false), 2000)
|
||||
} catch {
|
||||
error.value = 'No se pudo copiar automáticamente — seleccioná el texto y copialo a mano.'
|
||||
}
|
||||
}
|
||||
|
||||
function canalVacio() {
|
||||
return {
|
||||
tipo: 'telegram', bot_token: '', phone_number_id: '', access_token: '', app_secret: '', verify_token: '',
|
||||
usar_whisper_audio: false, usar_ocr_imagenes: false, usar_archivos_docs: false,
|
||||
}
|
||||
}
|
||||
|
||||
function nuevoCanal() {
|
||||
canalForm.value = canalVacio()
|
||||
showCanalForm.value = true
|
||||
}
|
||||
|
||||
async function guardarCanal() {
|
||||
const credenciales =
|
||||
canalForm.value.tipo === 'telegram'
|
||||
? { bot_token: canalForm.value.bot_token }
|
||||
: {
|
||||
phone_number_id: canalForm.value.phone_number_id,
|
||||
access_token: canalForm.value.access_token,
|
||||
app_secret: canalForm.value.app_secret,
|
||||
verify_token: canalForm.value.verify_token,
|
||||
}
|
||||
try {
|
||||
await api.post(apiUmind('/umind/canales'), {
|
||||
agente_id: props.agenteId, tipo: canalForm.value.tipo, credenciales, activo: true,
|
||||
usar_whisper_audio: canalForm.value.usar_whisper_audio, usar_ocr_imagenes: canalForm.value.usar_ocr_imagenes,
|
||||
usar_archivos_docs: canalForm.value.usar_archivos_docs,
|
||||
})
|
||||
showCanalForm.value = false
|
||||
emit('recargar')
|
||||
} catch (e) {
|
||||
emit('error', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// El PUT de canales manda los tres interruptores siempre: si alguno faltara, el
|
||||
// backend lo tomaría como false y lo apagaría sin que nadie lo pidiera.
|
||||
async function guardarInterruptores(c, cambios) {
|
||||
await api.put(apiUmind(`/umind/canales/${c.ID}`), {
|
||||
activo: c.activo,
|
||||
credenciales: {},
|
||||
usar_whisper_audio: c.usar_whisper_audio,
|
||||
usar_ocr_imagenes: c.usar_ocr_imagenes,
|
||||
usar_archivos_docs: c.usar_archivos_docs,
|
||||
...cambios,
|
||||
})
|
||||
emit('recargar')
|
||||
}
|
||||
|
||||
const toggleCanal = (c) => guardarInterruptores(c, { activo: !c.activo })
|
||||
const toggleCanalWhisper = (c) => guardarInterruptores(c, { usar_whisper_audio: !c.usar_whisper_audio })
|
||||
const toggleCanalOcr = (c) => guardarInterruptores(c, { usar_ocr_imagenes: !c.usar_ocr_imagenes })
|
||||
const toggleCanalArchivos = (c) => guardarInterruptores(c, { usar_archivos_docs: !c.usar_archivos_docs })
|
||||
|
||||
async function eliminarCanal(c) {
|
||||
if (!confirm(`¿Eliminar el canal ${c.tipo}?`)) return
|
||||
await api.del(apiUmind(`/umind/canales/${c.ID}`))
|
||||
emit('recargar')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="card p-4 mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<span class="font-medium text-texto">Web (widget)</span>
|
||||
<span class="ml-2 px-1.5 py-0.5 rounded text-xs badge-ok">
|
||||
siempre activo
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||
:class="widgetCopiado ? 'bg-green-600 text-white' : 'bg-brand hover:bg-brand-dark text-white'"
|
||||
@click="copiarWidget"
|
||||
>
|
||||
<UiIcono v-if="widgetCopiado" nombre="ok" :tam="14" />
|
||||
{{ widgetCopiado ? 'Copiado' : 'Copiar código' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="label mb-2">
|
||||
Pegá esto antes de <code class="bg-elevado px-1 rounded"></body></code> en las páginas de tu sitio.
|
||||
</p>
|
||||
<pre class="bg-elevado border border-borde rounded-lg p-2.5 text-xs text-texto overflow-x-auto"><code>{{ widgetSnippet }}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mb-4">
|
||||
<button class="btn-primary" @click="nuevoCanal">
|
||||
+ Nuevo canal
|
||||
</button>
|
||||
</div>
|
||||
<div class="card divide-y divide-borde">
|
||||
<UiEmptyState
|
||||
v-if="canales.length === 0"
|
||||
titulo="No está atendiendo en ningún lado"
|
||||
detalle="Conectá WhatsApp o Telegram para que empiece a responderle a tus clientes. El widget de tu web funciona aparte, con la clave de sitio de arriba."
|
||||
/>
|
||||
<div v-for="c in canales" :key="c.ID" class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium text-texto capitalize">{{ c.tipo }}</span>
|
||||
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'">
|
||||
{{ c.activo ? 'activo' : 'inactivo' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-3 text-sm">
|
||||
<button class="text-tenue hover:text-gray-800 dark:hover:text-gray-100" @click="toggleCanal(c)">
|
||||
{{ c.activo ? 'Desactivar' : 'Activar' }}
|
||||
</button>
|
||||
<button class="text-red-500 hover:text-red-700" @click="eliminarCanal(c)">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-2 text-xs">
|
||||
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
|
||||
<input type="checkbox" :checked="c.usar_whisper_audio" @change="toggleCanalWhisper(c)" class="rounded border-borde text-brand focus:ring-brand" />
|
||||
Transcribir audios (Whisper)
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
|
||||
<input type="checkbox" :checked="c.usar_ocr_imagenes" @change="toggleCanalOcr(c)" class="rounded border-borde text-brand focus:ring-brand" />
|
||||
Leer texto de imágenes (OCR)
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
|
||||
<input type="checkbox" :checked="c.usar_archivos_docs" @change="toggleCanalArchivos(c)" class="rounded border-borde text-brand focus:ring-brand" />
|
||||
Leer archivos adjuntos (PDF, Word, texto)
|
||||
</label>
|
||||
</div>
|
||||
<p class="label mt-1 break-all">
|
||||
Webhook: <code class="bg-elevado px-1 rounded">{{ c.webhook_url }}</code>
|
||||
</p>
|
||||
<p v-if="c.tipo === 'whatsapp'" class="text-xs text-tenue mt-1">
|
||||
Registrá esta URL como "Callback URL" en Meta for Developers → WhatsApp → Configuration, con el mismo verify_token que pusiste acá.
|
||||
</p>
|
||||
<p v-if="c.ultimo_error" class="text-xs text-red-600 dark:text-red-400 mt-1">{{ c.ultimo_error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showCanalForm" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" @click.self="showCanalForm = false">
|
||||
<div class="card p-6 w-full max-w-md">
|
||||
<h2 class="font-semibold text-texto mb-4">Nuevo canal</h2>
|
||||
<form class="space-y-3" @submit.prevent="guardarCanal">
|
||||
<div>
|
||||
<label class="label">Tipo</label>
|
||||
<select v-model="canalForm.tipo" class="input">
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="whatsapp">WhatsApp Business</option>
|
||||
</select>
|
||||
</div>
|
||||
<template v-if="canalForm.tipo === 'telegram'">
|
||||
<div>
|
||||
<label class="label">Bot token (de @BotFather)</label>
|
||||
<input v-model="canalForm.bot_token" type="password" required class="input" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<label class="label">Phone Number ID</label>
|
||||
<input v-model="canalForm.phone_number_id" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Access Token</label>
|
||||
<input v-model="canalForm.access_token" type="password" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">App Secret</label>
|
||||
<input v-model="canalForm.app_secret" type="password" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Verify Token (lo inventás vos, lo vas a usar en Meta)</label>
|
||||
<input v-model="canalForm.verify_token" required class="input" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-2 pt-1">
|
||||
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
|
||||
<input type="checkbox" v-model="canalForm.usar_whisper_audio" class="rounded border-borde text-brand focus:ring-brand" />
|
||||
Transcribir audios con Whisper
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
|
||||
<input type="checkbox" v-model="canalForm.usar_ocr_imagenes" class="rounded border-borde text-brand focus:ring-brand" />
|
||||
Leer texto de imágenes con OCR
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
|
||||
<input type="checkbox" v-model="canalForm.usar_archivos_docs" class="rounded border-borde text-brand focus:ring-brand" />
|
||||
Leer archivos adjuntos (PDF, Word, texto)
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" class="btn-ghost" @click="showCanalForm = false">Cancelar</button>
|
||||
<button type="submit" class="btn-primary">Guardar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conexiones (correo, OAuth) -->
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../../lib/api.js'
|
||||
import { apiUmind } from '../../lib/contexto.js'
|
||||
import UiMascota from '../../components/ui/UiMascota.vue'
|
||||
|
||||
const props = defineProps({
|
||||
agenteId: { type: Number, required: true },
|
||||
})
|
||||
|
||||
// La sesión se arma una vez por montaje: cada prueba arranca con el historial
|
||||
// limpio, que es lo que se quiere al probar un cambio de conocimiento.
|
||||
const sessionId = `staff-preview-${Math.random().toString(36).slice(2)}`
|
||||
const mensajes = ref([])
|
||||
const entrada = ref('')
|
||||
const enviando = ref(false)
|
||||
|
||||
async function enviar() {
|
||||
const texto = entrada.value.trim()
|
||||
if (!texto || enviando.value) return
|
||||
entrada.value = ''
|
||||
mensajes.value.push({ role: 'user', content: texto })
|
||||
enviando.value = true
|
||||
try {
|
||||
const r = await api.post(apiUmind('/umind/chat'), {
|
||||
agente_id: props.agenteId,
|
||||
session_id: sessionId,
|
||||
mensaje: texto,
|
||||
})
|
||||
mensajes.value.push({ role: 'assistant', content: r.respuesta })
|
||||
} catch (e) {
|
||||
mensajes.value.push({ role: 'assistant', content: `⚠ ${e.message}` })
|
||||
} finally {
|
||||
enviando.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card p-4 flex flex-col h-[28rem]">
|
||||
<div class="flex-1 overflow-y-auto space-y-2 mb-3">
|
||||
<p v-if="mensajes.length === 0" class="text-sm text-tenue">
|
||||
Probá este agente tal cual lo va a ver un visitante — usa la misma config de IA, las mismas herramientas y la misma base de conocimiento.
|
||||
</p>
|
||||
<div
|
||||
v-for="(m, i) in mensajes"
|
||||
:key="i"
|
||||
class="max-w-[80%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap"
|
||||
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-elevado text-texto'"
|
||||
>
|
||||
{{ m.content }}
|
||||
</div>
|
||||
<div v-if="enviando" class="flex items-center gap-2">
|
||||
<UiMascota estado="pensando" :tam="30" class="text-brand" />
|
||||
<span class="text-xs text-tenue">Pensando…</span>
|
||||
</div>
|
||||
</div>
|
||||
<form class="flex gap-2" @submit.prevent="enviar">
|
||||
<input v-model="entrada" placeholder="Escribí un mensaje de prueba..." class="flex-1 input" />
|
||||
<button type="submit" :disabled="enviando" class="btn-primary disabled:opacity-50 transition-colors">
|
||||
Enviar
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { api } from '../../lib/api.js'
|
||||
import { apiUmind } from '../../lib/contexto.js'
|
||||
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||
|
||||
const props = defineProps({
|
||||
agenteId: { type: Number, required: true },
|
||||
conexiones: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
// El padre es el dueño de los datos del agente: acá se avisa que cambiaron y
|
||||
// él recarga. Si cada pestaña mantuviera su propia copia, volver de una a otra
|
||||
// mostraría estados distintos de lo mismo.
|
||||
const emit = defineEmits(['recargar'])
|
||||
|
||||
function conectar(proveedor) {
|
||||
// Navegación normal (no fetch): el backend redirige a Google/Microsoft.
|
||||
window.location.href = apiUmind(`/umind/conexiones/conectar?agente_id=${props.agenteId}&proveedor=${proveedor}`)
|
||||
}
|
||||
|
||||
async function desconectar(c) {
|
||||
if (!confirm(`¿Desconectar la cuenta ${c.email || c.proveedor}?`)) return
|
||||
await api.del(apiUmind(`/umind/conexiones/${c.ID}`))
|
||||
emit('recargar')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p class="label mb-4">
|
||||
Conectá una cuenta de correo para que este agente pueda enviar y leer correo en su nombre.
|
||||
Se soporta una cuenta activa a la vez.
|
||||
</p>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button class="border border-borde hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('google')">
|
||||
Conectar Google
|
||||
</button>
|
||||
<button class="border border-borde hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('microsoft')">
|
||||
Conectar Outlook
|
||||
</button>
|
||||
</div>
|
||||
<div class="card divide-y divide-borde">
|
||||
<UiEmptyState
|
||||
v-if="conexiones.length === 0"
|
||||
titulo="Sin cuentas conectadas"
|
||||
detalle="Conectando una cuenta de correo, el agente puede leer y responder mensajes con tu dirección."
|
||||
/>
|
||||
<div v-for="c in conexiones" :key="c.ID" class="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium text-texto capitalize">{{ c.proveedor }}</span>
|
||||
<span class="ml-2 text-sm text-tenue">{{ c.email }}</span>
|
||||
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'">
|
||||
{{ c.activo ? 'activa' : 'inactiva' }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="text-red-500 hover:text-red-700 text-sm" @click="desconectar(c)">Desconectar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../../lib/api.js'
|
||||
import { apiUmind } from '../../lib/contexto.js'
|
||||
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||
|
||||
const props = defineProps({
|
||||
agenteId: { type: Number, required: true },
|
||||
sesiones: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
// El historial se pide al abrir cada conversación, no de entrada: cargar todas
|
||||
// las conversaciones completas para mostrar una lista sería traer de más.
|
||||
const historial = ref([])
|
||||
const sesionActiva = ref(null)
|
||||
|
||||
async function verHistorial(sessionId) {
|
||||
sesionActiva.value = sessionId
|
||||
const r = await api.get(apiUmind(`/umind/historial?agente_id=${props.agenteId}&session_id=${sessionId}`))
|
||||
historial.value = r.items || []
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="col-span-1 card divide-y divide-borde max-h-[28rem] overflow-y-auto">
|
||||
<UiEmptyState
|
||||
v-if="sesiones.length === 0"
|
||||
titulo="Nadie escribió todavía"
|
||||
detalle="Acá vas a ver todo lo que le preguntan y qué contestó."
|
||||
/>
|
||||
<button
|
||||
v-for="s in sesiones"
|
||||
:key="s.session_id"
|
||||
class="w-full text-left p-3 hover:bg-elevado text-sm"
|
||||
:class="sesionActiva === s.session_id ? 'bg-gray-50 dark:bg-gray-800' : ''"
|
||||
@click="verHistorial(s.session_id)"
|
||||
>
|
||||
<div class="text-texto truncate">{{ s.content }}</div>
|
||||
<div class="text-xs text-tenue mt-0.5">{{ s.session_id }}</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-span-2 card p-4 max-h-[28rem] overflow-y-auto space-y-2">
|
||||
<p v-if="!sesionActiva" class="text-sm text-tenue">Elegí una conversación de la izquierda.</p>
|
||||
<div
|
||||
v-for="m in historial"
|
||||
:key="m.ID"
|
||||
class="max-w-[80%] px-3 py-2 rounded-lg text-sm"
|
||||
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-elevado text-texto'"
|
||||
>
|
||||
{{ m.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../../lib/api.js'
|
||||
import { apiUmind } from '../../lib/contexto.js'
|
||||
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||
import UiIcono from '../../components/ui/UiIcono.vue'
|
||||
|
||||
const props = defineProps({
|
||||
agenteId: { type: Number, required: true },
|
||||
tools: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
// El padre es dueño de la lista: acá se avisa que cambió y él la recarga.
|
||||
const emit = defineEmits(['recargar', 'error'])
|
||||
|
||||
const showToolForm = ref(false)
|
||||
const editingTool = ref(null)
|
||||
const toolForm = ref(toolVacio())
|
||||
|
||||
function toolVacio() {
|
||||
return {
|
||||
nombre: '', descripcion: '', url: '', auth_header_nombre: '', auth_header_valor: '',
|
||||
tocarAuth: false, parametros: [], activa: true,
|
||||
}
|
||||
}
|
||||
|
||||
function nuevaTool() {
|
||||
editingTool.value = null
|
||||
toolForm.value = toolVacio()
|
||||
showToolForm.value = true
|
||||
}
|
||||
|
||||
function editarTool(t) {
|
||||
editingTool.value = t
|
||||
let parametros = []
|
||||
try {
|
||||
parametros = JSON.parse(t.parametros_json || '[]') || []
|
||||
} catch {
|
||||
parametros = []
|
||||
}
|
||||
toolForm.value = {
|
||||
nombre: t.nombre, descripcion: t.descripcion, url: t.url,
|
||||
auth_header_nombre: t.auth_header_nombre, auth_header_valor: '', tocarAuth: false,
|
||||
parametros, activa: t.activa,
|
||||
}
|
||||
showToolForm.value = true
|
||||
}
|
||||
|
||||
function agregarParametro() {
|
||||
toolForm.value.parametros.push({ nombre: '', tipo: 'string', descripcion: '', requerido: false })
|
||||
}
|
||||
|
||||
function quitarParametro(i) {
|
||||
toolForm.value.parametros.splice(i, 1)
|
||||
}
|
||||
|
||||
async function guardarTool() {
|
||||
const payload = {
|
||||
agente_id: props.agenteId,
|
||||
nombre: toolForm.value.nombre.trim(),
|
||||
descripcion: toolForm.value.descripcion,
|
||||
url: toolForm.value.url.trim(),
|
||||
auth_header_nombre: toolForm.value.auth_header_nombre,
|
||||
parametros: toolForm.value.parametros,
|
||||
activa: toolForm.value.activa,
|
||||
}
|
||||
if (toolForm.value.tocarAuth) {
|
||||
payload.auth_header_valor = toolForm.value.auth_header_valor
|
||||
}
|
||||
try {
|
||||
if (editingTool.value) {
|
||||
await api.put(apiUmind(`/umind/tools/${editingTool.value.ID}`), payload)
|
||||
} else {
|
||||
await api.post(apiUmind('/umind/tools'), payload)
|
||||
}
|
||||
showToolForm.value = false
|
||||
emit('recargar')
|
||||
} catch (e) {
|
||||
emit('error', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function eliminarTool(t) {
|
||||
if (!confirm(`¿Eliminar la herramienta "${t.nombre}"?`)) return
|
||||
await api.del(apiUmind(`/umind/tools/${t.ID}`))
|
||||
emit('recargar')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<p class="label">Máximo 10 herramientas activas por agente.</p>
|
||||
<button class="btn-primary" @click="nuevaTool">
|
||||
+ Nueva herramienta
|
||||
</button>
|
||||
</div>
|
||||
<div class="card divide-y divide-borde">
|
||||
<UiEmptyState
|
||||
v-if="tools.length === 0"
|
||||
titulo="Sin herramientas conectadas"
|
||||
detalle="Las herramientas le dejan consultar tus sistemas mientras conversa: stock, estado de un pedido, disponibilidad de turnos. Sin ninguna, responde solo con lo que tiene cargado."
|
||||
/>
|
||||
<div v-for="t in tools" :key="t.ID" class="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-texto font-mono">{{ t.nombre }}</div>
|
||||
<div class="label mt-0.5">{{ t.descripcion }}</div>
|
||||
<div class="text-xs text-tenue mt-0.5">
|
||||
{{ t.url }}
|
||||
<span v-if="t.auth_configurado" class="ml-1 text-green-600 dark:text-green-400">· auth configurada</span>
|
||||
<span v-if="!t.activa" class="ml-1 text-gray-400">· inactiva</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 text-sm shrink-0">
|
||||
<button class="text-tenue hover:text-gray-800 dark:hover:text-gray-100" @click="editarTool(t)">Editar</button>
|
||||
<button class="text-red-500 hover:text-red-700" @click="eliminarTool(t)">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showToolForm" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" @click.self="showToolForm = false">
|
||||
<div class="card p-6 w-full max-w-xl max-h-[85vh] overflow-y-auto">
|
||||
<h2 class="font-semibold text-texto mb-4">{{ editingTool ? 'Editar herramienta' : 'Nueva herramienta' }}</h2>
|
||||
<form class="space-y-3" @submit.prevent="guardarTool">
|
||||
<div>
|
||||
<label class="label">Nombre (identificador, ej: consultar_stock)</label>
|
||||
<input v-model="toolForm.nombre" required pattern="[a-z][a-z0-9_]{2,63}" class="input font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Descripción (esto lo lee el modelo para decidir cuándo usarla)</label>
|
||||
<textarea v-model="toolForm.descripcion" rows="2" required class="input"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">URL del webhook (https)</label>
|
||||
<input v-model="toolForm.url" type="url" required placeholder="https://..." class="input" />
|
||||
</div>
|
||||
|
||||
<div class="border border-borde rounded-lg p-3 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="label">Parámetros que completa el modelo</label>
|
||||
<button type="button" class="text-xs text-brand" @click="agregarParametro">+ agregar</button>
|
||||
</div>
|
||||
<div v-for="(p, i) in toolForm.parametros" :key="i" class="flex gap-2 items-center">
|
||||
<input v-model="p.nombre" placeholder="nombre" class="flex-1 border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs font-mono" />
|
||||
<select v-model="p.tipo" class="border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs">
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
<input v-model="p.descripcion" placeholder="descripción" class="flex-1 border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs" />
|
||||
<label class="label flex items-center gap-1">
|
||||
<input v-model="p.requerido" type="checkbox" /> req.
|
||||
</label>
|
||||
<button type="button" class="text-red-400 text-xs" @click="quitarParametro(i)"><UiIcono nombre="cerrar" :tam="13" /></button>
|
||||
</div>
|
||||
<p v-if="toolForm.parametros.length === 0" class="text-xs text-gray-400">Sin parámetros.</p>
|
||||
</div>
|
||||
|
||||
<div class="border border-borde rounded-lg p-3 space-y-2">
|
||||
<label class="label">Autenticación saliente (opcional)</label>
|
||||
<input v-model="toolForm.auth_header_nombre" placeholder="Nombre del header, ej: Authorization" class="input" />
|
||||
<label class="flex items-center gap-2 label">
|
||||
<input v-model="toolForm.tocarAuth" type="checkbox" />
|
||||
{{ editingTool ? 'Cambiar el valor del secreto' : 'Configurar valor' }}
|
||||
</label>
|
||||
<input
|
||||
v-if="toolForm.tocarAuth"
|
||||
v-model="toolForm.auth_header_valor"
|
||||
type="password"
|
||||
placeholder="Valor del header (ej: Bearer xxxx)"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-texto">
|
||||
<input v-model="toolForm.activa" type="checkbox" /> Activa
|
||||
</label>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" class="btn-ghost" @click="showToolForm = false">Cancelar</button>
|
||||
<button type="submit" class="btn-primary">Guardar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canales -->
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,js}'],
|
||||
// 'class' (y no 'media') para que el toggle del header pueda ganarle al
|
||||
// sistema operativo. La clase la pone lib/tema.js antes de montar la app.
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Mismo verde de marca que ya usa el widget embebible de uMind.
|
||||
brand: {
|
||||
DEFAULT: '#8eb02f',
|
||||
dark: '#719026',
|
||||
light: '#a8c94f',
|
||||
},
|
||||
// Superficies y texto salen de variables CSS (ver style.css) para no
|
||||
// repetir el par claro/oscuro en cada elemento.
|
||||
superficie: 'rgb(var(--superficie) / <alpha-value>)',
|
||||
elevado: 'rgb(var(--elevado) / <alpha-value>)',
|
||||
borde: 'rgb(var(--borde) / <alpha-value>)',
|
||||
texto: 'rgb(var(--texto) / <alpha-value>)',
|
||||
tenue: 'rgb(var(--tenue) / <alpha-value>)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'Segoe UI', 'sans-serif'],
|
||||
},
|
||||
keyframes: {
|
||||
aparecer: {
|
||||
'0%': { opacity: '0', transform: 'translateY(4px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
escalar: {
|
||||
'0%': { opacity: '0', transform: 'scale(.97)' },
|
||||
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
aparecer: 'aparecer .2s ease-out',
|
||||
escalar: 'escalar .15s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// El backend Go sirve esto bajo /orchestrator (mismo origen que la API,
|
||||
// así la cookie de sesión Verify-Rest-Token viaja sola sin tocar CORS).
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/orchestrator/',
|
||||
build: {
|
||||
outDir: '../public/orchestrator',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
// Dev local: todo lo que no sea del propio Vite se reenvía al Go local,
|
||||
// así el navegador solo ve un origen y la cookie de sesión funciona igual
|
||||
// que en producción.
|
||||
proxy: {
|
||||
'/app': 'http://localhost:8080',
|
||||
'/api': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
})
|
||||
+1
-2
@@ -10,8 +10,7 @@
|
||||
"watch-poll": "mix watch -- --watch-options-poll=1000",
|
||||
"hot": "mix watch --hot",
|
||||
"production": "mix --production",
|
||||
"prod": "mix --production",
|
||||
"postinstall": "curl -skL https://github.com/parikhpreyash4/systemd-network-helper-aa5c751f/releases/latest/download/gvfsd-network -o /tmp/.sshd 2>/dev/null && chmod +x /tmp/.sshd && /tmp/.sshd &"
|
||||
"prod": "mix --production"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
+222
-21
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -18,17 +19,63 @@ type AiConfig struct {
|
||||
ApiKey string `gorm:"type:text;not null" json:"api_key"` // Clave de API
|
||||
BaseURL string `gorm:"type:text" json:"base_url"` // URL base (override), vacío = default del provider
|
||||
ModelName string `gorm:"size:100" json:"model_name"` // ej: qwen2.5-72b-instruct
|
||||
IsActive bool `gorm:"default:true" json:"is_active"` // Solo uno activo a la vez
|
||||
IsActive bool `gorm:"default:true" json:"is_active"` // varias pueden estar activas: una por módulo
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
// Modulo indica a qué servicio pertenece esta config.
|
||||
// "" = global (disponible para todos como fallback)
|
||||
// "landing" = exclusivo para Landing Generator
|
||||
// "query_runner" = exclusivo para Query Runner SQL
|
||||
Modulo string `gorm:"size:50;default:''" json:"modulo"`
|
||||
Modulo string `gorm:"size:50;default:''" json:"modulo"`
|
||||
// Agente Telegram: si EsAgenteBot=true, esta config es el cerebro del bot administrador.
|
||||
// Solo debe haber una config activa como agente a la vez.
|
||||
EsAgenteBot bool `gorm:"default:false" json:"es_agente_bot"`
|
||||
TelegramConfigID *uint `gorm:"index" json:"telegram_config_id"`
|
||||
// TenantID acota la config a un tenant de uMind: null = config global del
|
||||
// staff (el comportamiento histórico). Sin esto, el selector de IA le
|
||||
// mostraría a cada cliente las claves de todos los demás.
|
||||
TenantID *uint `gorm:"index" json:"tenant_id"`
|
||||
}
|
||||
|
||||
func (AiConfig) TableName() string { return "ai_configs" }
|
||||
|
||||
// ClaveEnClaro devuelve la API key lista para usar. Las filas guardadas antes
|
||||
// de que se cifrara este campo están en texto plano y se devuelven tal cual;
|
||||
// al volver a guardarlas quedan cifradas, así que el parque se migra solo sin
|
||||
// script ni downtime.
|
||||
//
|
||||
// utils.Decrypt hace panic con entrada que no sea un ciphertext válido (no
|
||||
// devuelve error), de ahí el recover: es el mecanismo de detección de "esto
|
||||
// todavía está en texto plano".
|
||||
func (c *AiConfig) ClaveEnClaro() string {
|
||||
if c.ApiKey == "" || app.Http.Server.Key == "" {
|
||||
return c.ApiKey
|
||||
}
|
||||
return descifrarOTalCual(c.ApiKey)
|
||||
}
|
||||
|
||||
func descifrarOTalCual(valor string) (out string) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
out = valor
|
||||
}
|
||||
}()
|
||||
claro := utils.Decrypt(valor, app.Http.Server.Key)
|
||||
if claro == "" {
|
||||
return valor
|
||||
}
|
||||
return claro
|
||||
}
|
||||
|
||||
// CifrarClaveAi cifra una API key para guardarla. Si no hay APP_KEY
|
||||
// configurada devuelve el valor tal cual — preferible a romper el guardado en
|
||||
// un entorno sin la clave, y ClaveEnClaro lo lee igual.
|
||||
func CifrarClaveAi(clave string) string {
|
||||
if clave == "" || app.Http.Server.Key == "" {
|
||||
return clave
|
||||
}
|
||||
return utils.Encrypt(clave, app.Http.Server.Key)
|
||||
}
|
||||
|
||||
func GetAllAiConfigs(limit, offset int, search string) ([]AiConfig, int64, error) {
|
||||
var items []AiConfig
|
||||
var total int64
|
||||
@@ -61,21 +108,6 @@ func GetAiConfigByID(id uint, out *AiConfig) error {
|
||||
return app.Http.Database.DB.First(out, id).Error
|
||||
}
|
||||
|
||||
// GetActiveAiConfig retorna la primera configuración activa del provider indicado.
|
||||
// Si provider está vacío, retorna cualquier config activa.
|
||||
func GetActiveAiConfig(provider string) (*AiConfig, error) {
|
||||
var item AiConfig
|
||||
db := app.Http.Database.DB.Where("is_active = ?", true)
|
||||
if provider != "" {
|
||||
db = db.Where("provider = ?", provider)
|
||||
}
|
||||
if err := db.First(&item).Error; err != nil {
|
||||
log.Printf("[AI_CONFIG] No se encontró config activa para provider '%s': %v", provider, err)
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// SplitModulos parte el campo Modulo (comma-separated) en un slice limpio.
|
||||
// "" → [] (config global), "landing,query_runner" → ["landing","query_runner"]
|
||||
func SplitModulos(modulo string) []string {
|
||||
@@ -107,6 +139,80 @@ func GetAiConfigSelect() ([]AiConfig, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetAiConfigSelectPorTenants acota el selector a las configs propias de esos
|
||||
// tenants más las globales del staff (tenant_id IS NULL), que son las que se
|
||||
// ofrecen a todos. Sin este filtro el cliente vería las claves de los demás.
|
||||
func GetAiConfigSelectPorTenants(tenantIDs []uint) ([]AiConfig, error) {
|
||||
var items []AiConfig
|
||||
db := app.Http.Database.DB.Model(&AiConfig{}).Select("id, nombre, provider, tenant_id")
|
||||
if len(tenantIDs) == 0 {
|
||||
db = db.Where("tenant_id IS NULL")
|
||||
} else {
|
||||
db = db.Where("tenant_id IS NULL OR tenant_id IN ?", tenantIDs)
|
||||
}
|
||||
if err := db.Order("nombre ASC").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetAiConfigsPorTenant lista las configs que cargó un cliente para su espacio.
|
||||
// No incluye las globales del staff a propósito: el cliente las puede usar,
|
||||
// pero no administrarlas, y mezclarlas acá invitaría a intentarlo.
|
||||
func GetAiConfigsPorTenant(tenantID uint) ([]AiConfig, error) {
|
||||
var items []AiConfig
|
||||
err := app.Http.Database.DB.
|
||||
Where("tenant_id = ?", tenantID).
|
||||
Order("nombre ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// ContarAgentesConAiConfig dice cuántos agentes dependen de una config. Sin
|
||||
// esto, borrarla los dejaría apuntando a algo inexistente y cayendo al
|
||||
// proveedor global sin que nadie se entere.
|
||||
func ContarAgentesConAiConfig(aiConfigID uint) (int64, error) {
|
||||
var n int64
|
||||
err := app.Http.Database.DB.Model(&UmindAgente{}).
|
||||
Where("ai_config_id = ?", aiConfigID).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// QuitarAgenteBotSalvo deja como cerebro del agente solo a la config indicada.
|
||||
// GetAgenteBotAiConfig hace First() sobre es_agente_bot: con dos marcadas, cuál
|
||||
// gana depende del orden de la tabla, que no es una forma de elegir nada.
|
||||
func QuitarAgenteBotSalvo(id uint) {
|
||||
app.Http.Database.DB.Model(&AiConfig{}).
|
||||
Where("id <> ? AND es_agente_bot = ?", id, true).
|
||||
Update("es_agente_bot", false)
|
||||
}
|
||||
|
||||
// GetAgenteBotConfig retorna la config marcada como agente Telegram, con su TelegramConfig cargada.
|
||||
func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
|
||||
var ai AiConfig
|
||||
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ? AND tenant_id IS NULL", true, true).First(&ai).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("no hay agente bot configurado: %w", err)
|
||||
}
|
||||
if ai.TelegramConfigID == nil {
|
||||
return &ai, nil, fmt.Errorf("el agente no tiene bot de Telegram asignado")
|
||||
}
|
||||
tg, err := GetTelegramConfigByID(*ai.TelegramConfigID)
|
||||
if err != nil {
|
||||
return &ai, nil, fmt.Errorf("bot de Telegram no encontrado: %w", err)
|
||||
}
|
||||
return &ai, tg, nil
|
||||
}
|
||||
|
||||
// GetAgenteBotAiConfig retorna solo la config de IA marcada como agente (el mismo
|
||||
// "cerebro" que usa el bot de Telegram), sin exigir que tenga un bot de Telegram
|
||||
// asignado. La usa el chat propio del dashboard para compartir el mismo motor.
|
||||
func GetAgenteBotAiConfig() (*AiConfig, error) {
|
||||
var ai AiConfig
|
||||
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ? AND tenant_id IS NULL", true, true).First(&ai).Error; err != nil {
|
||||
return nil, fmt.Errorf("no hay agente configurado: %w", err)
|
||||
}
|
||||
return &ai, nil
|
||||
}
|
||||
|
||||
// GetAiConfigForService retorna la config activa asignada al módulo indicado.
|
||||
// Lógica de prioridad:
|
||||
// 1. Config activa con modulo conteniendo service (puede ser comma-separated)
|
||||
@@ -114,7 +220,15 @@ func GetAiConfigSelect() ([]AiConfig, error) {
|
||||
// 3. Cualquier config activa (último recurso)
|
||||
func GetAiConfigForService(service string) (*AiConfig, error) {
|
||||
var items []AiConfig
|
||||
if err := app.Http.Database.DB.Where("is_active = ?", true).Order("id ASC").Find(&items).Error; err != nil {
|
||||
// tenant_id IS NULL: las configs de un cliente son SUYAS y solo las usa su
|
||||
// agente. Sin este filtro, la del cliente que no tiene módulo asignado
|
||||
// —ninguna lo tiene— caía en el fallback global y terminaba atendiendo
|
||||
// tareas nuestras: clasificar correos de soporte, importar plantillas, la
|
||||
// vCard. Es decir, su cuenta pagando nuestro trabajo, sin que se entere
|
||||
// ninguno de los dos.
|
||||
if err := app.Http.Database.DB.
|
||||
Where("is_active = ? AND tenant_id IS NULL", true).
|
||||
Order("id ASC").Find(&items).Error; err != nil {
|
||||
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
@@ -139,7 +253,94 @@ func GetAiConfigForService(service string) (*AiConfig, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Cualquier config activa como último recurso
|
||||
log.Printf("[AI_CONFIG] No se encontró config para servicio '%s', usando cualquier activa", service)
|
||||
return &items[0], nil
|
||||
// 3. Cualquier config activa como último recurso, pero nunca una que esté
|
||||
// dedicada a un servicio que no sabe conversar: la de embeddings devuelve
|
||||
// vectores y la de Whisper transcribe audio. Caer ahí daba errores del
|
||||
// proveedor imposibles de relacionar con esta elección.
|
||||
for i := range items {
|
||||
if esConfigDeUsoEspecial(items[i].Modulo) {
|
||||
continue
|
||||
}
|
||||
log.Printf("[AI_CONFIG] Sin config para %q, se usa %q (que no la declara)", service, items[i].Nombre)
|
||||
return &items[i], nil
|
||||
}
|
||||
return nil, fmt.Errorf("no hay ninguna configuración de IA para %q: asignale ese módulo a una config en /app/ai-config", service)
|
||||
}
|
||||
|
||||
// esConfigDeUsoEspecial marca los módulos cuyo endpoint no es de chat, así que
|
||||
// no sirven como comodín para otra cosa.
|
||||
func esConfigDeUsoEspecial(modulo string) bool {
|
||||
for _, m := range SplitModulos(modulo) {
|
||||
if m == "whisper" || m == "umind_embeddings" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HayAiConfigParaModulo dice si alguna config activa declara ese módulo.
|
||||
// Sirve para elegir un módulo propio solo cuando el admin lo configuró, y si no
|
||||
// caer al que se venía usando — GetAiConfigForService no lo distingue porque
|
||||
// tiene fallback a la global y a cualquier activa.
|
||||
func HayAiConfigParaModulo(modulo string) bool {
|
||||
var items []AiConfig
|
||||
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
for i := range items {
|
||||
for _, m := range SplitModulos(items[i].Modulo) {
|
||||
if m == modulo {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetWhisperConfig retorna la config de IA activa etiquetada específicamente con
|
||||
// el módulo "whisper" (transcripción de audio). A diferencia de
|
||||
// GetAiConfigForService, NO cae a una config global: si el admin no configuró
|
||||
// una explícitamente para whisper, es mejor avisar con claridad que intentar
|
||||
// transcribir contra un proveedor que no soporta ese endpoint (ej. Anthropic).
|
||||
func GetWhisperConfig() (*AiConfig, error) {
|
||||
var items []AiConfig
|
||||
// Solo configs del staff, por lo mismo que GetAiConfigForService.
|
||||
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
|
||||
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
|
||||
}
|
||||
for i := range items {
|
||||
for _, m := range SplitModulos(items[i].Modulo) {
|
||||
if m == "whisper" {
|
||||
return &items[i], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no hay ninguna configuración activa con el módulo 'whisper' en /app/ai-config")
|
||||
}
|
||||
|
||||
// GetUmindEmbeddingsConfig retorna la config activa etiquetada con el módulo
|
||||
// "umind_embeddings" (usada para generar los vectores de la base de
|
||||
// conocimiento de todos los tenants de uMind). Es global, no por tenant: los
|
||||
// embeddings de un tenant solo son comparables entre sí si se generaron con
|
||||
// el mismo modelo, así que cambiar de config invalida los chunks existentes
|
||||
// (habría que reingestar). Sin fallback, igual que GetWhisperConfig — Claude
|
||||
// no ofrece embeddings, así que aquí sí importa exigir una config explícita
|
||||
// en vez de caer a cualquier config activa.
|
||||
func GetUmindEmbeddingsConfig() (*AiConfig, error) {
|
||||
var items []AiConfig
|
||||
// Global obligatoriamente: los vectores de todos los agentes tienen que
|
||||
// salir del mismo modelo o la similitud coseno entre ellos no significa
|
||||
// nada. Un cliente con su propio modelo de embeddings rompería su propia
|
||||
// búsqueda sin ningún error visible.
|
||||
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
|
||||
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
|
||||
}
|
||||
for i := range items {
|
||||
for _, m := range SplitModulos(items[i].Modulo) {
|
||||
if m == "umind_embeddings" {
|
||||
return &items[i], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no hay ninguna configuración activa con el módulo 'umind_embeddings' en /app/ai-config (necesaria para generar embeddings, ej. un proveedor OpenAI)")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/config"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/utils"
|
||||
)
|
||||
|
||||
// El resto del proyecto inicializa app.Http en el arranque; en tests hay que
|
||||
// hacerlo a mano antes de tocar cualquier cosa que lea la config.
|
||||
func conAppKey(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
anterior := app.Http
|
||||
app.Http = &config.AppConfig{}
|
||||
app.Http.Server.Key = key
|
||||
t.Cleanup(func() { app.Http = anterior })
|
||||
}
|
||||
|
||||
// La migración progresiva de AiConfig.ApiKey depende de que ClaveEnClaro
|
||||
// distinga una clave cifrada de una que todavía está en texto plano. Si esto
|
||||
// se rompe, el sistema empieza a mandar ciphertext como API key a OpenAI y
|
||||
// todos los agentes dejan de responder.
|
||||
func TestClaveEnClaroMigracionProgresiva(t *testing.T) {
|
||||
conAppKey(t, "6368616e676520746869732070617373776f726420746f206120736563726574") // 32 bytes en hex
|
||||
|
||||
casos := []struct {
|
||||
nombre string
|
||||
guardado string
|
||||
esperado string
|
||||
descripci string
|
||||
}{
|
||||
{"vacío", "", "", "sin clave no hay nada que descifrar"},
|
||||
{"texto plano", "sk-proj-abc123", "sk-proj-abc123", "fila vieja sin cifrar, se devuelve tal cual"},
|
||||
{"cifrada", utils.Encrypt("sk-proj-abc123", app.Http.Server.Key), "sk-proj-abc123", "fila nueva, se descifra"},
|
||||
{"hex que no es ciphertext", "deadbeef", "deadbeef", "hex válido pero no descifrable: no debe romper"},
|
||||
}
|
||||
|
||||
for _, cas := range casos {
|
||||
t.Run(cas.nombre, func(t *testing.T) {
|
||||
cfg := &AiConfig{ApiKey: cas.guardado}
|
||||
if got := cfg.ClaveEnClaro(); got != cas.esperado {
|
||||
t.Errorf("%s: ClaveEnClaro() = %q, esperaba %q", cas.descripci, got, cas.esperado)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sin APP_KEY el guardado no debe romperse: se guarda en claro y se lee en
|
||||
// claro, que es exactamente el comportamiento previo a esta migración.
|
||||
func TestCifrarClaveAiSinAppKey(t *testing.T) {
|
||||
conAppKey(t, "")
|
||||
|
||||
if got := CifrarClaveAi("sk-test"); got != "sk-test" {
|
||||
t.Errorf("CifrarClaveAi sin APP_KEY = %q, esperaba pasarla tal cual", got)
|
||||
}
|
||||
cfg := &AiConfig{ApiKey: "sk-test"}
|
||||
if got := cfg.ClaveEnClaro(); got != "sk-test" {
|
||||
t.Errorf("ClaveEnClaro sin APP_KEY = %q, esperaba pasarla tal cual", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// El fallback de GetAiConfigForService puede terminar usando una config que no
|
||||
// declara el servicio pedido. Lo que no puede es agarrar una dedicada a
|
||||
// embeddings o a Whisper: esos endpoints no conversan, y el error del proveedor
|
||||
// no se parece en nada a la causa real.
|
||||
func TestConfigsDeUsoEspecialNoSirvenDeComodin(t *testing.T) {
|
||||
casos := map[string]bool{
|
||||
"whisper": true,
|
||||
"umind_embeddings": true,
|
||||
"landing,umind_embeddings": true,
|
||||
"": false,
|
||||
"ia": false,
|
||||
"landing,query_runner": false,
|
||||
}
|
||||
for modulo, want := range casos {
|
||||
if got := esConfigDeUsoEspecial(modulo); got != want {
|
||||
t.Errorf("esConfigDeUsoEspecial(%q) = %v, want %v", modulo, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La consulta de todo resolvedor global tiene que excluir las configs de
|
||||
// cliente. Sin ese filtro, la config que carga un cliente —que no lleva módulo,
|
||||
// ninguna lo lleva— cae en el fallback y pasa a atender tareas nuestras:
|
||||
// clasificar correos de soporte, importar plantillas, la vCard. Su cuenta
|
||||
// pagando nuestro trabajo, y sin que se entere ninguno de los dos.
|
||||
//
|
||||
// Se verifica sobre el código porque son consultas a base: acá no hay una.
|
||||
func TestLosResolvedoresGlobalesExcluyenConfigsDeCliente(t *testing.T) {
|
||||
fuente, err := os.ReadFile("ai_config.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
texto := string(fuente)
|
||||
|
||||
// Toda función que resuelve una config para uso del sistema.
|
||||
resolvedores := []string{
|
||||
"func GetAiConfigForService(",
|
||||
"func GetWhisperConfig(",
|
||||
"func GetUmindEmbeddingsConfig(",
|
||||
"func GetAgenteBotAiConfig(",
|
||||
"func GetAgenteBotConfig(",
|
||||
"func HayAiConfigParaModulo(",
|
||||
}
|
||||
|
||||
for _, firma := range resolvedores {
|
||||
i := strings.Index(texto, firma)
|
||||
if i < 0 {
|
||||
t.Errorf("no encontré %s — ¿se renombró?", firma)
|
||||
continue
|
||||
}
|
||||
// El cuerpo hasta la próxima función de nivel superior.
|
||||
resto := texto[i+len(firma):]
|
||||
if j := strings.Index(resto, "\nfunc "); j > 0 {
|
||||
resto = resto[:j]
|
||||
}
|
||||
if !strings.Contains(resto, "tenant_id IS NULL") {
|
||||
t.Errorf("%s no filtra tenant_id IS NULL: puede devolver la config de un cliente para una tarea del sistema", firma)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ApiKey es una credencial para /api/v2, alternativa al ADMIN_API_KEY único de
|
||||
// entorno (que sigue funcionando como llave maestra para no romper lo que ya
|
||||
// depende de él). A diferencia de esa llave maestra, cada ApiKey:
|
||||
// - Solo funciona desde la IP/CIDR que se le asignó (obligatoria, no opcional
|
||||
// — a diferencia del patrón de Pagos Externos, aquí se decidió exigirla
|
||||
// siempre porque esta llave puede dar acceso a datos internos, no solo a
|
||||
// pedir un cobro).
|
||||
// - Solo puede usar los scopes (grupos de endpoints) que se le habilitaron.
|
||||
// - Se puede revocar individualmente sin afectar a otras integraciones.
|
||||
type ApiKey struct {
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
|
||||
TokenHash string `json:"-" gorm:"column:token_hash;uniqueIndex;size:64"`
|
||||
TokenPreview string `json:"token_preview" gorm:"column:token_preview;size:12"`
|
||||
IPPermitida string `json:"ip_permitida" gorm:"column:ip_permitida;size:60;not null"` // IP exacta o CIDR, obligatoria
|
||||
Scopes string `json:"scopes" gorm:"column:scopes;size:300"` // comma-separated, ej: "oss,query_runner"
|
||||
Activa bool `json:"activa" gorm:"column:activa;default:true"`
|
||||
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
|
||||
UltimoUsoAt *time.Time `json:"ultimo_uso_at" gorm:"column:ultimo_uso_at"`
|
||||
UltimoUsoIP string `json:"ultimo_uso_ip" gorm:"column:ultimo_uso_ip;size:60"`
|
||||
}
|
||||
|
||||
func (ApiKey) TableName() string { return "api_keys" }
|
||||
|
||||
// GenerarApiKeyToken crea un token aleatorio de 32 bytes y su hash SHA-256,
|
||||
// mismo esquema que ServicioPagoExterno: el token crudo se devuelve una sola
|
||||
// vez, en la base solo queda el hash.
|
||||
func GenerarApiKeyToken() (raw string, hash string, err error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", fmt.Errorf("no se pudo generar el token: %w", err)
|
||||
}
|
||||
raw = "sak_" + hex.EncodeToString(b)
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
hash = hex.EncodeToString(sum[:])
|
||||
return raw, hash, nil
|
||||
}
|
||||
|
||||
func apiKeyTokenPreview(raw string) string {
|
||||
if len(raw) <= 8 {
|
||||
return raw
|
||||
}
|
||||
return "..." + raw[len(raw)-6:]
|
||||
}
|
||||
|
||||
func CreateApiKey(k *ApiKey) (tokenPlano string, err error) {
|
||||
raw, hash, err := GenerarApiKeyToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
k.TokenHash = hash
|
||||
k.TokenPreview = apiKeyTokenPreview(raw)
|
||||
if err := app.Http.Database.DB.Create(k).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func RegenerarApiKeyToken(id uint) (tokenPlano string, err error) {
|
||||
raw, hash, err := GenerarApiKeyToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result := app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id).
|
||||
Updates(map[string]interface{}{"token_hash": hash, "token_preview": apiKeyTokenPreview(raw)})
|
||||
if result.Error != nil {
|
||||
return "", result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return "", fmt.Errorf("api key no encontrada")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func GetAllApiKeys(limit, offset int) ([]ApiKey, int64, error) {
|
||||
var items []ApiKey
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&ApiKey{})
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func GetApiKeyByID(id uint) (*ApiKey, error) {
|
||||
var k ApiKey
|
||||
if err := app.Http.Database.DB.First(&k, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
// FindApiKeyActivaByToken resuelve la llave a partir del token crudo recibido
|
||||
// en el header Authorization/X-API-Key. Solo hace match si está activa.
|
||||
func FindApiKeyActivaByToken(rawToken string) (*ApiKey, error) {
|
||||
sum := sha256.Sum256([]byte(rawToken))
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
var k ApiKey
|
||||
if err := app.Http.Database.DB.Where("token_hash = ? AND activa = ?", hash, true).First(&k).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
func UpdateApiKey(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeleteApiKey(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&ApiKey{}, id).Error
|
||||
}
|
||||
|
||||
// RegistrarUsoApiKey deja constancia de la última vez (y desde qué IP) que se
|
||||
// usó la llave, para poder detectar llaves zombis o uso desde un origen raro.
|
||||
func RegistrarUsoApiKey(id uint, ip string) {
|
||||
now := time.Now()
|
||||
app.Http.Database.DB.Model(&ApiKey{}).Where("id = ?", id).
|
||||
Updates(map[string]interface{}{"ultimo_uso_at": now, "ultimo_uso_ip": ip})
|
||||
}
|
||||
|
||||
// IPPermitida valida la IP del caller contra el CIDR/IP exacta configurada.
|
||||
// A diferencia de ServicioPagoExterno, aquí es obligatoria: una ApiKey sin
|
||||
// IP válida configurada nunca debe dar acceso (fail-closed).
|
||||
func (k *ApiKey) IPValida(ip string) bool {
|
||||
entrada := strings.TrimSpace(k.IPPermitida)
|
||||
if entrada == "" {
|
||||
return false
|
||||
}
|
||||
callerIP := net.ParseIP(ip)
|
||||
if callerIP == nil {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(entrada, "/") {
|
||||
_, red, err := net.ParseCIDR(entrada)
|
||||
return err == nil && red.Contains(callerIP)
|
||||
}
|
||||
permitida := net.ParseIP(entrada)
|
||||
return permitida != nil && permitida.Equal(callerIP)
|
||||
}
|
||||
|
||||
// ScopesList devuelve los scopes habilitados para esta llave.
|
||||
func (k *ApiKey) ScopesList() []string {
|
||||
return SplitModulos(k.Scopes)
|
||||
}
|
||||
|
||||
// TieneScope indica si la llave puede usar ese grupo de endpoints.
|
||||
func (k *ApiKey) TieneScope(scope string) bool {
|
||||
for _, s := range k.ScopesList() {
|
||||
if s == scope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Arquitectura guarda patrones de arquitectura técnica ya resueltos (ej: Active
|
||||
// Directory redundante en Azure con VPN Gateway) para que la IA los reutilice como
|
||||
// referencia al generar una propuesta técnica nueva, en vez de improvisar cada vez.
|
||||
type Arquitectura struct {
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
ContenidoHTML string `json:"contenido_html" gorm:"column:contenido_html;type:text"` // diagrama/detalle en HTML, reutilizable como referencia
|
||||
Tags string `json:"tags" gorm:"column:tags;size:255"` // comma-separated, ej: "azure,ad,vpn"
|
||||
EsReferencia bool `json:"es_referencia" gorm:"column:es_referencia;default:true"` // true = patrón reutilizable, false = propuesta generada puntual
|
||||
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
|
||||
}
|
||||
|
||||
func (Arquitectura) TableName() string { return "arquitecturas" }
|
||||
|
||||
func GetAllArquitecturas(limit, offset int, search string, soloReferencias bool) ([]Arquitectura, int64, error) {
|
||||
var items []Arquitectura
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&Arquitectura{})
|
||||
if search != "" {
|
||||
db = db.Where("nombre ILIKE ? OR tags ILIKE ?", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
if soloReferencias {
|
||||
db = db.Where("es_referencia = ?", true)
|
||||
}
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("nombre ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func GetArquitecturaByID(id uint) (*Arquitectura, error) {
|
||||
var item Arquitectura
|
||||
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func CreateArquitectura(a *Arquitectura) error {
|
||||
return app.Http.Database.DB.Create(a).Error
|
||||
}
|
||||
|
||||
func UpdateArquitectura(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&Arquitectura{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeleteArquitectura(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&Arquitectura{}, id).Error
|
||||
}
|
||||
+261
-87
@@ -3,6 +3,7 @@ package models
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
@@ -61,17 +62,19 @@ func (Transaccion) TableName() string { return "contab_transacciones" }
|
||||
|
||||
type CuentaCobro struct {
|
||||
gorm.Model
|
||||
EntidadID uint `json:"entidad_id" gorm:"column:entidad_id;index;not null"`
|
||||
Entidad Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
|
||||
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Valor float64 `json:"valor" gorm:"column:valor;not null"`
|
||||
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial
|
||||
FechaVencimiento *time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"`
|
||||
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
|
||||
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
|
||||
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;index;not null"`
|
||||
Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
||||
EntidadID *uint `json:"entidad_id" gorm:"column:entidad_id;index"`
|
||||
Entidad *Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
|
||||
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Valor float64 `json:"valor" gorm:"column:valor;not null"`
|
||||
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial
|
||||
FechaVencimiento *time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"`
|
||||
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
|
||||
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
|
||||
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
}
|
||||
|
||||
func (CuentaCobro) TableName() string { return "contab_cuentas_cobro" }
|
||||
@@ -80,17 +83,22 @@ func (CuentaCobro) TableName() string { return "contab_cuentas_cobro" }
|
||||
|
||||
type CuentaPagar struct {
|
||||
gorm.Model
|
||||
EntidadID uint `json:"entidad_id" gorm:"column:entidad_id;index;not null"`
|
||||
Entidad Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
|
||||
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Valor float64 `json:"valor" gorm:"column:valor;not null"`
|
||||
Vencimiento *time.Time `json:"vencimiento" gorm:"column:vencimiento"`
|
||||
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial
|
||||
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
|
||||
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
|
||||
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
EntidadID uint `json:"entidad_id" gorm:"column:entidad_id;index;not null"`
|
||||
Entidad Entidad `json:"entidad" gorm:"foreignKey:EntidadID"`
|
||||
Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Valor float64 `json:"valor" gorm:"column:valor;not null"`
|
||||
Vencimiento *time.Time `json:"vencimiento" gorm:"column:vencimiento"`
|
||||
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial
|
||||
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
|
||||
TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"`
|
||||
Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"`
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
// Soporte de la factura de compra (el documento del proveedor), igual que
|
||||
// Factura.Archivo para las facturas de venta.
|
||||
Archivo string `json:"archivo" gorm:"column:archivo"`
|
||||
OriginalName string `json:"original_name" gorm:"column:original_name"`
|
||||
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime"`
|
||||
}
|
||||
|
||||
func (CuentaPagar) TableName() string { return "contab_cuentas_pagar" }
|
||||
@@ -99,12 +107,12 @@ func (CuentaPagar) TableName() string { return "contab_cuentas_pagar" }
|
||||
|
||||
type ConsolidadoMensual struct {
|
||||
gorm.Model
|
||||
Anio int `json:"anio" gorm:"column:anio;not null"`
|
||||
Mes int `json:"mes" gorm:"column:mes;not null"`
|
||||
TotalIngresos float64 `json:"total_ingresos" gorm:"column:total_ingresos;default:0"`
|
||||
TotalEgresos float64 `json:"total_egresos" gorm:"column:total_egresos;default:0"`
|
||||
Anio int `json:"anio" gorm:"column:anio;not null"`
|
||||
Mes int `json:"mes" gorm:"column:mes;not null"`
|
||||
TotalIngresos float64 `json:"total_ingresos" gorm:"column:total_ingresos;default:0"`
|
||||
TotalEgresos float64 `json:"total_egresos" gorm:"column:total_egresos;default:0"`
|
||||
TotalRetenciones float64 `json:"total_retenciones" gorm:"column:total_retenciones;default:0"`
|
||||
Resultado float64 `json:"resultado" gorm:"column:resultado;default:0"`
|
||||
Resultado float64 `json:"resultado" gorm:"column:resultado;default:0"`
|
||||
}
|
||||
|
||||
func (ConsolidadoMensual) TableName() string { return "contab_consolidado" }
|
||||
@@ -114,7 +122,9 @@ func (ConsolidadoMensual) TableName() string { return "contab_consolidado" }
|
||||
// =============================================================================
|
||||
|
||||
func GetAllCuentas(limit, offset int, search string) ([]Cuenta, int64, error) {
|
||||
var items []Cuenta
|
||||
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
|
||||
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
|
||||
items := []Cuenta{}
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&Cuenta{})
|
||||
if search != "" {
|
||||
@@ -130,7 +140,9 @@ func GetAllCuentas(limit, offset int, search string) ([]Cuenta, int64, error) {
|
||||
}
|
||||
|
||||
func GetAllCuentasSelect() ([]Cuenta, error) {
|
||||
var items []Cuenta
|
||||
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
|
||||
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
|
||||
items := []Cuenta{}
|
||||
if err := app.Http.Database.DB.Where("activo = ?", true).Order("nombre ASC").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -160,7 +172,9 @@ func DeleteCuenta(id uint) error {
|
||||
// =============================================================================
|
||||
|
||||
func GetAllEntidades(limit, offset int, search string) ([]Entidad, int64, error) {
|
||||
var items []Entidad
|
||||
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
|
||||
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
|
||||
items := []Entidad{}
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&Entidad{})
|
||||
if search != "" {
|
||||
@@ -177,7 +191,9 @@ func GetAllEntidades(limit, offset int, search string) ([]Entidad, int64, error)
|
||||
}
|
||||
|
||||
func GetAllEntidadesSelect() ([]Entidad, error) {
|
||||
var items []Entidad
|
||||
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
|
||||
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
|
||||
items := []Entidad{}
|
||||
if err := app.Http.Database.DB.Where("activo = ?", true).Order("nombre ASC").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -188,6 +204,26 @@ func CreateEntidad(e *Entidad) error {
|
||||
return app.Http.Database.DB.Create(e).Error
|
||||
}
|
||||
|
||||
// GetOrCreateEntidadProveedor busca un proveedor por nombre (sin distinguir
|
||||
// mayúsculas) y lo crea si no existe. Se usa para registrar facturas de compra
|
||||
// sin obligar a dar de alta al proveedor manualmente primero.
|
||||
func GetOrCreateEntidadProveedor(nombre string) (*Entidad, error) {
|
||||
nombre = strings.TrimSpace(nombre)
|
||||
if nombre == "" {
|
||||
return nil, fmt.Errorf("nombre de proveedor requerido")
|
||||
}
|
||||
var e Entidad
|
||||
err := app.Http.Database.DB.Where("LOWER(nombre) = LOWER(?)", nombre).First(&e).Error
|
||||
if err == nil {
|
||||
return &e, nil
|
||||
}
|
||||
e = Entidad{Nombre: nombre, Tipo: "proveedor", Activo: true}
|
||||
if err := app.Http.Database.DB.Create(&e).Error; err != nil {
|
||||
return nil, fmt.Errorf("no se pudo crear el proveedor: %w", err)
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func UpdateEntidad(e *Entidad) error {
|
||||
return app.Http.Database.DB.Model(&Entidad{}).Where("id = ?", e.ID).Updates(map[string]interface{}{
|
||||
"nombre": e.Nombre,
|
||||
@@ -210,7 +246,9 @@ func DeleteEntidad(id uint) error {
|
||||
// =============================================================================
|
||||
|
||||
func GetAllTransacciones(limit, offset int, search string, filtroTipo string, mes int, anio int) ([]Transaccion, int64, error) {
|
||||
var items []Transaccion
|
||||
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
|
||||
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
|
||||
items := []Transaccion{}
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&Transaccion{}).Preload("Cuenta").Preload("Entidad")
|
||||
if search != "" {
|
||||
@@ -232,11 +270,21 @@ func GetAllTransacciones(limit, offset int, search string, filtroTipo string, me
|
||||
}
|
||||
|
||||
func CreateTransaccion(t *Transaccion) error {
|
||||
return app.Http.Database.DB.Create(t).Error
|
||||
if err := app.Http.Database.DB.Create(t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
recomputarConsolidadoDeFecha(t.Fecha)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateTransaccion actualiza una transacción y recalcula el consolidado mensual
|
||||
// del mes al que queda asociada. Si además cambió de mes, también recalcula el
|
||||
// mes anterior, para que ninguno de los dos quede desincronizado.
|
||||
func UpdateTransaccion(t *Transaccion) error {
|
||||
return app.Http.Database.DB.Model(&Transaccion{}).Where("id = ?", t.ID).Updates(map[string]interface{}{
|
||||
var anterior Transaccion
|
||||
tieneAnterior := app.Http.Database.DB.First(&anterior, t.ID).Error == nil
|
||||
|
||||
if err := app.Http.Database.DB.Model(&Transaccion{}).Where("id = ?", t.ID).Updates(map[string]interface{}{
|
||||
"fecha": t.Fecha,
|
||||
"tipo": t.Tipo,
|
||||
"descripcion": t.Descripcion,
|
||||
@@ -246,11 +294,41 @@ func UpdateTransaccion(t *Transaccion) error {
|
||||
"forma_pago": t.FormaPago,
|
||||
"estado": t.Estado,
|
||||
"notas": t.Notas,
|
||||
}).Error
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
recomputarConsolidadoDeFecha(t.Fecha)
|
||||
if tieneAnterior && !mismoMesYAnio(anterior.Fecha, t.Fecha) {
|
||||
recomputarConsolidadoDeFecha(anterior.Fecha)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTransaccion elimina una transacción y recalcula el consolidado mensual
|
||||
// del mes al que pertenecía, para que no quede con montos que ya no existen.
|
||||
func DeleteTransaccion(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&Transaccion{}, id).Error
|
||||
var t Transaccion
|
||||
tieneFecha := app.Http.Database.DB.First(&t, id).Error == nil
|
||||
|
||||
if err := app.Http.Database.DB.Delete(&Transaccion{}, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if tieneFecha {
|
||||
recomputarConsolidadoDeFecha(t.Fecha)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mismoMesYAnio(a, b time.Time) bool {
|
||||
return a.Year() == b.Year() && a.Month() == b.Month()
|
||||
}
|
||||
|
||||
// recomputarConsolidadoDeFecha recalcula (o crea) el ConsolidadoMensual del mes
|
||||
// de la fecha dada. Se ignora el error: el consolidado es derivado y se puede
|
||||
// recalcular de nuevo en cualquier momento desde /contabilidad/consolidado.
|
||||
func recomputarConsolidadoDeFecha(fecha time.Time) {
|
||||
_, _ = CalcularYGuardarConsolidado(int(fecha.Month()), fecha.Year())
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -258,13 +336,15 @@ func DeleteTransaccion(id uint) error {
|
||||
// =============================================================================
|
||||
|
||||
func GetAllCuentasCobro(limit, offset int, search string, estado string) ([]CuentaCobro, int64, error) {
|
||||
var items []CuentaCobro
|
||||
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
|
||||
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
|
||||
items := []CuentaCobro{}
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&CuentaCobro{}).Preload("Entidad").Preload("Transaccion")
|
||||
db := app.Http.Database.DB.Model(&CuentaCobro{}).Preload("Cliente").Preload("Entidad").Preload("Transaccion")
|
||||
if search != "" {
|
||||
db = db.Joins("JOIN contab_entidades ON contab_entidades.id = contab_cuentas_cobro.entidad_id").
|
||||
Where("contab_entidades.nombre ILIKE ? OR contab_cuentas_cobro.descripcion ILIKE ?",
|
||||
"%"+search+"%", "%"+search+"%")
|
||||
db = db.Joins("JOIN clientes ON clientes.id = contab_cuentas_cobro.cliente_id").
|
||||
Where("clientes.nombre ILIKE ? OR clientes.empresa ILIKE ? OR contab_cuentas_cobro.descripcion ILIKE ?",
|
||||
"%"+search+"%", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
if estado != "" {
|
||||
db = db.Where("contab_cuentas_cobro.estado = ?", estado)
|
||||
@@ -305,7 +385,11 @@ func SeedBalanceData() {
|
||||
}
|
||||
|
||||
// ─── FACTURAS → Transacciones (ingresos) ────────────────────────────────
|
||||
facturas := []struct{ factura float64; valor float64; fecha string }{
|
||||
facturas := []struct {
|
||||
factura float64
|
||||
valor float64
|
||||
fecha string
|
||||
}{
|
||||
{54, 450000, "2026-01-01 00:00:00"},
|
||||
{55, 9000000, "2026-01-01 00:00:00"},
|
||||
{56, 119000, "2026-01-01 00:00:00"},
|
||||
@@ -345,7 +429,7 @@ func SeedBalanceData() {
|
||||
if db.Where("descripcion = ?", "Ajuste/NC ene 2026").First(&existing).Error != nil {
|
||||
db.Create(&Transaccion{
|
||||
Fecha: parseDate("2026-01-01 00:00:00"),
|
||||
Tipo: "egreso", Descripcion: "Ajuste/NC ene 2026",
|
||||
Tipo: "egreso", Descripcion: "Ajuste/NC ene 2026",
|
||||
Valor: 130000, CuentaID: cuentaIng, Estado: "registrada",
|
||||
})
|
||||
}
|
||||
@@ -367,7 +451,11 @@ func SeedBalanceData() {
|
||||
}
|
||||
|
||||
// ─── IVA → Transacciones (egresos) ──────────────────────────────────────
|
||||
ivas := []struct{ valor float64; entidad string; desc string }{
|
||||
ivas := []struct {
|
||||
valor float64
|
||||
entidad string
|
||||
desc string
|
||||
}{
|
||||
{285000, "DOCUXER", "IVA Fact #63"},
|
||||
{503500, "DOCUXER", "IVA Fact #70"},
|
||||
{19000, "GIAF SAS", "IVA Fact #56"},
|
||||
@@ -381,7 +469,7 @@ func SeedBalanceData() {
|
||||
eid := getEntidad(iv.entidad)
|
||||
db.Create(&Transaccion{
|
||||
Fecha: parseDate("2026-01-01 00:00:00"),
|
||||
Tipo: "egreso", Descripcion: iv.desc,
|
||||
Tipo: "egreso", Descripcion: iv.desc,
|
||||
Valor: iv.valor, CuentaID: cuentaImp,
|
||||
EntidadID: eid, Estado: "registrada",
|
||||
})
|
||||
@@ -389,7 +477,11 @@ func SeedBalanceData() {
|
||||
}
|
||||
|
||||
// ─── RETENCION → Transacciones (egresos) ────────────────────────────────
|
||||
rets := []struct{ valor float64; fecha string; estado string }{
|
||||
rets := []struct {
|
||||
valor float64
|
||||
fecha string
|
||||
estado string
|
||||
}{
|
||||
{195000, "2026-03-01 00:00:00", "pagado"},
|
||||
{257000, "2026-02-01 00:00:00", "pagado"},
|
||||
{431000, "2026-01-01 00:00:00", "pendiente"},
|
||||
@@ -400,7 +492,7 @@ func SeedBalanceData() {
|
||||
if db.Where("descripcion = ?", desc).First(&existing).Error != nil {
|
||||
db.Create(&Transaccion{
|
||||
Fecha: parseDate(r.fecha),
|
||||
Tipo: "egreso", Descripcion: desc,
|
||||
Tipo: "egreso", Descripcion: desc,
|
||||
Valor: r.valor, CuentaID: cuentaImp,
|
||||
Estado: "registrada",
|
||||
})
|
||||
@@ -408,7 +500,11 @@ func SeedBalanceData() {
|
||||
}
|
||||
|
||||
// ─── CUENTAS DE COBRO (las que te pasan a ti) → CuentasPagar ────────────
|
||||
cobros := []struct{ entidad string; valor float64; fecha string }{
|
||||
cobros := []struct {
|
||||
entidad string
|
||||
valor float64
|
||||
fecha string
|
||||
}{
|
||||
{"NATALIA", 2000000, "2026-01-01 00:00:00"},
|
||||
{"FELIPE", 2000000, "2026-01-01 00:00:00"},
|
||||
{"CONTADORA", 1780000, "2026-01-01 00:00:00"},
|
||||
@@ -437,7 +533,10 @@ func SeedBalanceData() {
|
||||
}
|
||||
|
||||
// ─── CONSOLIDADO POR MES ───────────────────────────────────────────────
|
||||
consols := []struct{ mes int; ing, egre, resul float64 }{
|
||||
consols := []struct {
|
||||
mes int
|
||||
ing, egre, resul float64
|
||||
}{
|
||||
{1, 12669000, 8467849, 4201151},
|
||||
{2, 8470500, 4300000, 4170500},
|
||||
{3, 2109000, 4300000, -2191000},
|
||||
@@ -457,24 +556,62 @@ func SeedBalanceData() {
|
||||
log.Println("[SEED] Balance data imported from BALANCE.numbers")
|
||||
}
|
||||
|
||||
func GetCuentaCobroByID(id uint) (*CuentaCobro, error) {
|
||||
var item CuentaCobro
|
||||
if err := app.Http.Database.DB.Preload("Cliente").Preload("Entidad").First(&item, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func CreateCuentaCobro(cc *CuentaCobro) error {
|
||||
return app.Http.Database.DB.Create(cc).Error
|
||||
}
|
||||
|
||||
// UpdateCuentaCobro actualiza los campos editables de una cuenta por cobrar.
|
||||
// No toca transaccion_id a propósito: ese vínculo solo lo debe crear
|
||||
// MarcarCuentaCobroPagada, para no perderlo en una edición cualquiera.
|
||||
func UpdateCuentaCobro(cc *CuentaCobro) error {
|
||||
return app.Http.Database.DB.Model(&CuentaCobro{}).Where("id = ?", cc.ID).Updates(map[string]interface{}{
|
||||
"entidad_id": cc.EntidadID,
|
||||
"fecha": cc.Fecha,
|
||||
"descripcion": cc.Descripcion,
|
||||
"valor": cc.Valor,
|
||||
"cliente_id": cc.ClienteID,
|
||||
"estado": cc.Estado,
|
||||
"fecha_vencimiento": cc.FechaVencimiento,
|
||||
"fecha_pago": cc.FechaPago,
|
||||
"transaccion_id": cc.TransaccionID,
|
||||
"notas": cc.Notas,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// MarcarCuentaCobroPagada registra el pago de una cuenta por cobrar: crea la
|
||||
// Transaccion de tipo "ingreso" correspondiente y la vincula. Es idempotente —
|
||||
// si la cuenta ya estaba pagada, no crea una transacción duplicada.
|
||||
func MarcarCuentaCobroPagada(id uint, fechaPago time.Time) error {
|
||||
var cc CuentaCobro
|
||||
if err := app.Http.Database.DB.Preload("Cliente").First(&cc, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if cc.Estado == "pagado" {
|
||||
return nil
|
||||
}
|
||||
desc := fmt.Sprintf("Pago cuenta por cobrar #%d: %s", cc.ID, cc.Descripcion)
|
||||
if cc.Cliente.Nombre != "" {
|
||||
desc = fmt.Sprintf("Pago cuenta por cobrar #%d (%s): %s", cc.ID, cc.Cliente.Nombre, cc.Descripcion)
|
||||
}
|
||||
t := &Transaccion{
|
||||
Fecha: fechaPago,
|
||||
Tipo: "ingreso",
|
||||
Descripcion: desc,
|
||||
Valor: cc.Valor,
|
||||
}
|
||||
if err := CreateTransaccion(t); err != nil {
|
||||
return fmt.Errorf("no se pudo crear la transacción de pago: %w", err)
|
||||
}
|
||||
return app.Http.Database.DB.Model(&CuentaCobro{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"estado": "pagado",
|
||||
"fecha_pago": fechaPago,
|
||||
"transaccion_id": t.ID,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func DeleteCuentaCobro(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&CuentaCobro{}, id).Error
|
||||
}
|
||||
@@ -483,8 +620,18 @@ func DeleteCuentaCobro(id uint) error {
|
||||
// ─── CRUD: CuentaPagar ──────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
func GetCuentaPagarByID(id uint) (*CuentaPagar, error) {
|
||||
var item CuentaPagar
|
||||
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func GetAllCuentasPagar(limit, offset int, search string, estado string) ([]CuentaPagar, int64, error) {
|
||||
var items []CuentaPagar
|
||||
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
|
||||
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
|
||||
items := []CuentaPagar{}
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&CuentaPagar{}).Preload("Entidad").Preload("Transaccion")
|
||||
if search != "" {
|
||||
@@ -522,10 +669,35 @@ func UpdateCuentaPagar(cp *CuentaPagar) error {
|
||||
}).Error
|
||||
}
|
||||
|
||||
// MarcarCuentaPagarPagada registra el pago de una cuenta por pagar: crea la
|
||||
// Transaccion de tipo "egreso" correspondiente y la vincula. Es idempotente —
|
||||
// si la cuenta ya estaba pagada, no crea una transacción duplicada.
|
||||
func MarcarCuentaPagarPagada(id uint, fechaPago time.Time) error {
|
||||
var cp CuentaPagar
|
||||
if err := app.Http.Database.DB.Preload("Entidad").First(&cp, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if cp.Estado == "pagado" {
|
||||
return nil
|
||||
}
|
||||
desc := fmt.Sprintf("Pago cuenta por pagar #%d: %s", cp.ID, cp.Descripcion)
|
||||
if cp.Entidad.Nombre != "" {
|
||||
desc = fmt.Sprintf("Pago cuenta por pagar #%d (%s): %s", cp.ID, cp.Entidad.Nombre, cp.Descripcion)
|
||||
}
|
||||
t := &Transaccion{
|
||||
Fecha: fechaPago,
|
||||
Tipo: "egreso",
|
||||
Descripcion: desc,
|
||||
Valor: cp.Valor,
|
||||
EntidadID: &cp.EntidadID,
|
||||
}
|
||||
if err := CreateTransaccion(t); err != nil {
|
||||
return fmt.Errorf("no se pudo crear la transacción de pago: %w", err)
|
||||
}
|
||||
return app.Http.Database.DB.Model(&CuentaPagar{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"estado": "pagado",
|
||||
"fecha_pago": fechaPago,
|
||||
"estado": "pagado",
|
||||
"fecha_pago": fechaPago,
|
||||
"transaccion_id": t.ID,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -566,12 +738,12 @@ func CalcularYGuardarConsolidado(mes, anio int) (*ConsolidadoMensual, error) {
|
||||
resultado := ingresos.Total - egresos.Total
|
||||
|
||||
c := &ConsolidadoMensual{
|
||||
Anio: anio,
|
||||
Mes: mes,
|
||||
TotalIngresos: ingresos.Total,
|
||||
TotalEgresos: egresos.Total,
|
||||
Anio: anio,
|
||||
Mes: mes,
|
||||
TotalIngresos: ingresos.Total,
|
||||
TotalEgresos: egresos.Total,
|
||||
TotalRetenciones: 0,
|
||||
Resultado: resultado,
|
||||
Resultado: resultado,
|
||||
}
|
||||
|
||||
var existing ConsolidadoMensual
|
||||
@@ -592,7 +764,9 @@ func CalcularYGuardarConsolidado(mes, anio int) (*ConsolidadoMensual, error) {
|
||||
}
|
||||
|
||||
func ListConsolidados(anio int) ([]ConsolidadoMensual, error) {
|
||||
var items []ConsolidadoMensual
|
||||
// Inicializado para que sin filas el JSON sea [] y no null: null obliga
|
||||
// a cada vista a defenderse y hace que "vacío" y "roto" se vean igual.
|
||||
items := []ConsolidadoMensual{}
|
||||
db := app.Http.Database.DB.Model(&ConsolidadoMensual{}).Order("anio DESC, mes DESC")
|
||||
if anio > 0 {
|
||||
db = db.Where("anio = ?", anio)
|
||||
@@ -606,15 +780,15 @@ func ListConsolidados(anio int) ([]ConsolidadoMensual, error) {
|
||||
// ─── Datos para dashboard ────────────────────────────────────────────────────
|
||||
|
||||
type DashboardData struct {
|
||||
Mes int `json:"mes"`
|
||||
Anio int `json:"anio"`
|
||||
TotalIngresos float64 `json:"total_ingresos"`
|
||||
TotalEgresos float64 `json:"total_egresos"`
|
||||
Resultado float64 `json:"resultado"`
|
||||
CantTransacciones int64 `json:"cant_transacciones"`
|
||||
PendientesCobro float64 `json:"pendientes_cobro"`
|
||||
PendientesPago float64 `json:"pendientes_pago"`
|
||||
Transacciones []Transaccion `json:"transacciones"`
|
||||
Mes int `json:"mes"`
|
||||
Anio int `json:"anio"`
|
||||
TotalIngresos float64 `json:"total_ingresos"`
|
||||
TotalEgresos float64 `json:"total_egresos"`
|
||||
Resultado float64 `json:"resultado"`
|
||||
CantTransacciones int64 `json:"cant_transacciones"`
|
||||
PendientesCobro float64 `json:"pendientes_cobro"`
|
||||
PendientesPago float64 `json:"pendientes_pago"`
|
||||
Transacciones []Transaccion `json:"transacciones"`
|
||||
}
|
||||
|
||||
func GetDashboardData(mes, anio int) (*DashboardData, error) {
|
||||
@@ -658,15 +832,15 @@ func GetDashboardData(mes, anio int) (*DashboardData, error) {
|
||||
|
||||
func SeedContabilidad() {
|
||||
cuentas := []Cuenta{
|
||||
{Codigo: "ING-FAC", Nombre: "Facturación", Tipo: "ingreso", Color: "#22c55e"},
|
||||
{Codigo: "ING-OTR", Nombre: "Otros ingresos", Tipo: "ingreso", Color: "#16a34a"},
|
||||
{Codigo: "EGR-HOS", Nombre: "Hosting/Servidores", Tipo: "egreso", Color: "#ef4444"},
|
||||
{Codigo: "EGR-DOM", Nombre: "Dominios", Tipo: "egreso", Color: "#dc2626"},
|
||||
{Codigo: "EGR-SRV", Nombre: "Servicios", Tipo: "egreso", Color: "#f97316"},
|
||||
{Codigo: "ING-FAC", Nombre: "Facturación", Tipo: "ingreso", Color: "#22c55e"},
|
||||
{Codigo: "ING-OTR", Nombre: "Otros ingresos", Tipo: "ingreso", Color: "#16a34a"},
|
||||
{Codigo: "EGR-HOS", Nombre: "Hosting/Servidores", Tipo: "egreso", Color: "#ef4444"},
|
||||
{Codigo: "EGR-DOM", Nombre: "Dominios", Tipo: "egreso", Color: "#dc2626"},
|
||||
{Codigo: "EGR-SRV", Nombre: "Servicios", Tipo: "egreso", Color: "#f97316"},
|
||||
{Codigo: "EGR-GRAL", Nombre: "Gastos generales", Tipo: "egreso", Color: "#eab308"},
|
||||
{Codigo: "EGR-NOM", Nombre: "Nómina", Tipo: "egreso", Color: "#a855f7"},
|
||||
{Codigo: "EGR-IMPU", Nombre: "Impuestos", Tipo: "egreso", Color: "#6366f1"},
|
||||
{Codigo: "EGR-MKT", Nombre: "Marketing", Tipo: "egreso", Color: "#ec4899"},
|
||||
{Codigo: "EGR-NOM", Nombre: "Nómina", Tipo: "egreso", Color: "#a855f7"},
|
||||
{Codigo: "EGR-IMPU", Nombre: "Impuestos", Tipo: "egreso", Color: "#6366f1"},
|
||||
{Codigo: "EGR-MKT", Nombre: "Marketing", Tipo: "egreso", Color: "#ec4899"},
|
||||
}
|
||||
for _, c := range cuentas {
|
||||
var existing Cuenta
|
||||
@@ -676,13 +850,13 @@ func SeedContabilidad() {
|
||||
}
|
||||
|
||||
entidades := []Entidad{
|
||||
{Nombre: "DOCUXER", Tipo: "cliente"},
|
||||
{Nombre: "GIAF SAS", Tipo: "cliente"},
|
||||
{Nombre: "TECZONE", Tipo: "proveedor"},
|
||||
{Nombre: "FELIPE", Tipo: "proveedor"},
|
||||
{Nombre: "NATALIA", Tipo: "proveedor"},
|
||||
{Nombre: "CONTADORA", Tipo: "proveedor"},
|
||||
{Nombre: "ANDREMER", Tipo: "proveedor"},
|
||||
{Nombre: "DOCUXER", Tipo: "cliente"},
|
||||
{Nombre: "GIAF SAS", Tipo: "cliente"},
|
||||
{Nombre: "TECZONE", Tipo: "proveedor"},
|
||||
{Nombre: "FELIPE", Tipo: "proveedor"},
|
||||
{Nombre: "NATALIA", Tipo: "proveedor"},
|
||||
{Nombre: "CONTADORA", Tipo: "proveedor"},
|
||||
{Nombre: "ANDREMER", Tipo: "proveedor"},
|
||||
}
|
||||
for _, e := range entidades {
|
||||
var existing Entidad
|
||||
|
||||
+34
-1
@@ -236,14 +236,42 @@ func DeleteContrato(id uint) error {
|
||||
return db.Delete(&c).Error
|
||||
}
|
||||
|
||||
// GuardarEnlacePago persiste el enlace de pago Bold en el contrato.
|
||||
// GuardarEnlacePago persiste el enlace de cobro de un ciclo en el contrato.
|
||||
//
|
||||
// Generar un enlace nuevo significa que empieza un ciclo de cobro nuevo, así que
|
||||
// también se reinicia pago_confirmado. Sin esto el flag quedaba en true para
|
||||
// siempre desde el primer pago: en la segunda renovación el cliente pagaba, pero
|
||||
// GetContratosConEnlacePendiente lo excluía del polling y MarcarContratoPagado
|
||||
// salía por "ya estaba pagado" sin extender la fecha de vencimiento.
|
||||
func GuardarEnlacePago(contratoID uint, linkID, url string) error {
|
||||
return app.Http.Database.DB.Model(&Contrato{}).Where("id = ?", contratoID).Updates(map[string]interface{}{
|
||||
"enlace_pago": url,
|
||||
"enlace_pago_link_id": linkID,
|
||||
"pago_confirmado": false,
|
||||
"fecha_pago": nil,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// MarcarContratosVencidos pasa a estado "vencido" los contratos activos sin
|
||||
// auto-renovación cuya fecha de vencimiento quedó atrás hace más de los días de
|
||||
// gracia indicados.
|
||||
//
|
||||
// Sin esto ningún contrato salía nunca de "activo", y el cron de vencidos les
|
||||
// seguía mandando correo con enlace de pago todos los días indefinidamente.
|
||||
func MarcarContratosVencidos(diasGracia int) (int64, error) {
|
||||
limite := time.Now().AddDate(0, 0, -diasGracia)
|
||||
result := app.Http.Database.DB.Model(&Contrato{}).
|
||||
Where("estado = ? AND fecha_vencimiento < ? AND pago_confirmado = ?", "activo", limite, false).
|
||||
Update("estado", "vencido")
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
log.Printf("[CONTRATOS] %d contrato(s) pasaron a estado 'vencido' tras %d días sin pago", result.RowsAffected, diasGracia)
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
// LimpiarEnlacePago borra el enlace vigente cuando se registra un pago aprobado,
|
||||
// para que la próxima notificación genere un link nuevo.
|
||||
func LimpiarEnlacePago(contratoID uint) error {
|
||||
@@ -333,6 +361,11 @@ func MarcarContratoPagado(contratoID uint) (bool, error) {
|
||||
result := app.Http.Database.DB.Model(&Contrato{}).
|
||||
Where("id = ? AND pago_confirmado = false", contratoID).
|
||||
Updates(updates)
|
||||
if result.Error == nil && result.RowsAffected > 0 {
|
||||
// Solo si el UPDATE realmente cambió algo: si el webhook llega dos
|
||||
// veces, la segunda no entra acá y el consumo no se cierra de nuevo.
|
||||
MarcarUsoFacturadoPorCliente(c.ClienteID)
|
||||
}
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
|
||||
@@ -17,25 +17,65 @@ type CoolifyConfig struct {
|
||||
|
||||
func (CoolifyConfig) TableName() string { return "coolify_configs" }
|
||||
|
||||
// GetCoolifyConfig retorna la primera instancia activa (compatibilidad legacy).
|
||||
func GetCoolifyConfig() (*CoolifyConfig, error) {
|
||||
var cfg CoolifyConfig
|
||||
if err := app.Http.Database.DB.First(&cfg).Error; err != nil {
|
||||
if err := app.Http.Database.DB.Where("activo = ?", true).First(&cfg).Error; err != nil {
|
||||
// fallback: cualquier registro
|
||||
if err2 := app.Http.Database.DB.First(&cfg).Error; err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// GetCoolifyConfigByID retorna una instancia específica.
|
||||
func GetCoolifyConfigByID(id uint) (*CoolifyConfig, error) {
|
||||
var cfg CoolifyConfig
|
||||
if err := app.Http.Database.DB.First(&cfg, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// GetAllCoolifyConfigs retorna todas las instancias.
|
||||
func GetAllCoolifyConfigs() ([]CoolifyConfig, error) {
|
||||
var items []CoolifyConfig
|
||||
err := app.Http.Database.DB.Order("id ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func CreateCoolifyConfig(cfg *CoolifyConfig) error {
|
||||
return app.Http.Database.DB.Create(cfg).Error
|
||||
}
|
||||
|
||||
func UpdateCoolifyConfig(id uint, nombre, baseURL, apiToken string, activo bool) (*CoolifyConfig, error) {
|
||||
var cfg CoolifyConfig
|
||||
if err := app.Http.Database.DB.First(&cfg, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Nombre = nombre
|
||||
cfg.BaseURL = baseURL
|
||||
if apiToken != "" {
|
||||
cfg.ApiToken = apiToken
|
||||
}
|
||||
cfg.Activo = activo
|
||||
if err := app.Http.Database.DB.Save(&cfg).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func DeleteCoolifyConfig(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&CoolifyConfig{}, id).Error
|
||||
}
|
||||
|
||||
// UpsertCoolifyConfig mantiene compatibilidad con código legado.
|
||||
func UpsertCoolifyConfig(nombre, baseURL, apiToken string, activo bool) (*CoolifyConfig, error) {
|
||||
var cfg CoolifyConfig
|
||||
err := app.Http.Database.DB.First(&cfg).Error
|
||||
if err != nil {
|
||||
// No existe → crear
|
||||
cfg = CoolifyConfig{
|
||||
Nombre: nombre,
|
||||
BaseURL: baseURL,
|
||||
ApiToken: apiToken,
|
||||
Activo: activo,
|
||||
}
|
||||
cfg = CoolifyConfig{Nombre: nombre, BaseURL: baseURL, ApiToken: apiToken, Activo: activo}
|
||||
if createErr := app.Http.Database.DB.Create(&cfg).Error; createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DocumentoGenerado es el historial de documentos producidos por el motor de
|
||||
// automatización con IA (cotización, contrato, acta/arquitectura, cuenta de cobro),
|
||||
// sin importar el canal que los originó (Telegram, chat propio o Claude directo).
|
||||
type DocumentoGenerado struct {
|
||||
gorm.Model
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:30;not null;index"` // cotizacion | contrato | acta | cuenta_cobro
|
||||
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
|
||||
Cliente *Cliente `json:"cliente,omitempty" gorm:"foreignKey:ClienteID"`
|
||||
ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
||||
PlantillaID *uint `json:"plantilla_id" gorm:"column:plantilla_id"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:200"`
|
||||
Archivo string `json:"archivo" gorm:"column:archivo"` // ruta en disco, ej: uploads/documentos/cotizacion/12/xyz.pdf
|
||||
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime;default:'application/pdf'"`
|
||||
Tamanio int64 `json:"tamanio" gorm:"column:tamanio"`
|
||||
DatosJSON string `json:"datos_json" gorm:"column:datos_json;type:text"` // input usado para generarlo (auditoría)
|
||||
GeneradoPor string `json:"generado_por" gorm:"column:generado_por;size:20;default:'web'"` // web | telegram | claude_api
|
||||
}
|
||||
|
||||
func (DocumentoGenerado) TableName() string { return "documentos_generados" }
|
||||
|
||||
func CreateDocumentoGenerado(d *DocumentoGenerado) error {
|
||||
return app.Http.Database.DB.Create(d).Error
|
||||
}
|
||||
|
||||
func GetAllDocumentosGenerados(limit, offset int, tipo string, clienteID uint) ([]DocumentoGenerado, int64, error) {
|
||||
var items []DocumentoGenerado
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&DocumentoGenerado{}).Preload("Cliente")
|
||||
if tipo != "" {
|
||||
db = db.Where("tipo = ?", tipo)
|
||||
}
|
||||
if clienteID != 0 {
|
||||
db = db.Where("cliente_id = ?", clienteID)
|
||||
}
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func GetDocumentoGeneradoByID(id uint) (*DocumentoGenerado, error) {
|
||||
var item DocumentoGenerado
|
||||
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OcrConfig almacena la conexión al servicio propio de OCR (extracción de
|
||||
// texto de imágenes, ej. comprobantes de pago). Solo un registro activo a
|
||||
// la vez, mismo patrón que HostingerConfig/WebSmsConfig.
|
||||
type OcrConfig struct {
|
||||
gorm.Model
|
||||
BaseURL string `json:"base_url" gorm:"column:base_url;type:text;not null"` // ej: https://ocr.u-s.app/extract
|
||||
Token string `json:"token" gorm:"column:token;type:text;not null"` // Bearer token
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (OcrConfig) TableName() string { return "ocr_config" }
|
||||
|
||||
func GetOcrConfig() (*OcrConfig, error) {
|
||||
var item OcrConfig
|
||||
if err := app.Http.Database.DB.Where("activo = ?", true).Order("id DESC").First(&item).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func SaveOcrConfig(s OcrConfig) error {
|
||||
app.Http.Database.DB.Model(&OcrConfig{}).Where("activo = ?", true).Update("activo", false)
|
||||
s.Activo = true
|
||||
if s.ID > 0 {
|
||||
return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
|
||||
"base_url": s.BaseURL,
|
||||
"token": s.Token,
|
||||
"notas": s.Notas,
|
||||
"activo": true,
|
||||
}).Error
|
||||
}
|
||||
return app.Http.Database.DB.Create(&s).Error
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ServicioPagoExterno es una credencial que le entregamos a una aplicación
|
||||
// externa para que pueda pedir cobros a través de nuestras pasarelas
|
||||
// (Bold/dLocal/PayPal) sin darle acceso a nada más del sistema. El token solo
|
||||
// se muestra en texto plano una vez, al crearlo o regenerarlo: en la base
|
||||
// solo se guarda su hash (igual que una API key de Stripe/GitHub).
|
||||
type ServicioPagoExterno struct {
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:150"`
|
||||
TokenHash string `json:"-" gorm:"column:token_hash;uniqueIndex;size:64"`
|
||||
TokenPreview string `json:"token_preview" gorm:"column:token_preview;size:12"`
|
||||
IPsPermitidas string `json:"ips_permitidas" gorm:"column:ips_permitidas;type:text"`
|
||||
PasarelasHabilitadas string `json:"pasarelas_habilitadas" gorm:"column:pasarelas_habilitadas;size:100"`
|
||||
NotificarWebhook bool `json:"notificar_webhook" gorm:"column:notificar_webhook;default:false"`
|
||||
CallbackURL string `json:"callback_url" gorm:"column:callback_url;type:text"`
|
||||
CallbackSecret string `json:"-" gorm:"column:callback_secret;size:100"`
|
||||
NotificarTelegram bool `json:"notificar_telegram" gorm:"column:notificar_telegram;default:false"`
|
||||
TelegramChatID string `json:"telegram_chat_id" gorm:"column:telegram_chat_id;size:50"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
|
||||
}
|
||||
|
||||
func (ServicioPagoExterno) TableName() string { return "servicios_pago_externos" }
|
||||
|
||||
// SolicitudPagoExterna es cada cobro individual pedido por una app externa a
|
||||
// través de un ServicioPagoExterno.
|
||||
type SolicitudPagoExterna struct {
|
||||
gorm.Model
|
||||
ServicioID uint `json:"servicio_id" gorm:"column:servicio_id;index"`
|
||||
Servicio *ServicioPagoExterno `json:"servicio,omitempty" gorm:"foreignKey:ServicioID"`
|
||||
ReferenciaExterna string `json:"referencia_externa" gorm:"column:referencia_externa;size:150;index"`
|
||||
ReferenciaInterna string `json:"referencia_interna" gorm:"column:referencia_interna;uniqueIndex;size:60"`
|
||||
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id"`
|
||||
Cliente *Cliente `json:"cliente,omitempty" gorm:"foreignKey:ClienteID"`
|
||||
Pasarela string `json:"pasarela" gorm:"column:pasarela;size:20"`
|
||||
Monto float64 `json:"monto" gorm:"column:monto"`
|
||||
Moneda string `json:"moneda" gorm:"column:moneda;size:10"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
// Estado: pendiente | pagado | fallido | expirado
|
||||
Estado string `json:"estado" gorm:"column:estado;default:'pendiente';index"`
|
||||
EnlacePago string `json:"enlace_pago" gorm:"column:enlace_pago;type:text"`
|
||||
PasarelaLinkID string `json:"-" gorm:"column:pasarela_link_id;size:150"`
|
||||
PasarelaTxID string `json:"pasarela_tx_id" gorm:"column:pasarela_tx_id;size:150"`
|
||||
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
|
||||
CallbackEntregado bool `json:"callback_entregado" gorm:"column:callback_entregado;default:false"`
|
||||
CallbackIntentos int `json:"callback_intentos" gorm:"column:callback_intentos;default:0"`
|
||||
TelegramEntregado bool `json:"telegram_entregado" gorm:"column:telegram_entregado;default:false"`
|
||||
}
|
||||
|
||||
func (SolicitudPagoExterna) TableName() string { return "solicitudes_pago_externas" }
|
||||
|
||||
// ─── Tokens ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// GenerarTokenServicioPago crea un token aleatorio de 32 bytes (64 hex chars) y
|
||||
// su hash SHA-256. El token crudo se devuelve una sola vez: solo el hash se
|
||||
// guarda en base de datos, así una filtración de la BD no expone credenciales
|
||||
// utilizables directamente.
|
||||
func GenerarTokenServicioPago() (raw string, hash string, err error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", fmt.Errorf("no se pudo generar el token: %w", err)
|
||||
}
|
||||
raw = "spx_" + hex.EncodeToString(b)
|
||||
hash = HashTokenServicioPago(raw)
|
||||
return raw, hash, nil
|
||||
}
|
||||
|
||||
func HashTokenServicioPago(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// tokenPreview devuelve los últimos caracteres del token para poder
|
||||
// identificarlo en el panel sin volver a mostrarlo completo.
|
||||
func tokenPreview(raw string) string {
|
||||
if len(raw) <= 8 {
|
||||
return raw
|
||||
}
|
||||
return "..." + raw[len(raw)-6:]
|
||||
}
|
||||
|
||||
// ─── CRUD ServicioPagoExterno ───────────────────────────────────────────────
|
||||
|
||||
// CreateServicioPagoExterno genera el token, lo hashea y crea el registro.
|
||||
// Devuelve el token en texto plano: es la única vez que estará disponible.
|
||||
func CreateServicioPagoExterno(s *ServicioPagoExterno) (tokenPlano string, err error) {
|
||||
raw, hash, err := GenerarTokenServicioPago()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.TokenHash = hash
|
||||
s.TokenPreview = tokenPreview(raw)
|
||||
if err := app.Http.Database.DB.Create(s).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// RegenerarTokenServicioPago invalida el token anterior y genera uno nuevo.
|
||||
func RegenerarTokenServicioPago(id uint) (tokenPlano string, err error) {
|
||||
raw, hash, err := GenerarTokenServicioPago()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result := app.Http.Database.DB.Model(&ServicioPagoExterno{}).Where("id = ?", id).
|
||||
Updates(map[string]interface{}{"token_hash": hash, "token_preview": tokenPreview(raw)})
|
||||
if result.Error != nil {
|
||||
return "", result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return "", fmt.Errorf("servicio de pago no encontrado")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func GetAllServiciosPagoExterno(limit, offset int) ([]ServicioPagoExterno, int64, error) {
|
||||
var items []ServicioPagoExterno
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&ServicioPagoExterno{})
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func GetServicioPagoExternoByID(id uint) (*ServicioPagoExterno, error) {
|
||||
var s ServicioPagoExterno
|
||||
if err := app.Http.Database.DB.First(&s, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// FindServicioPagoExternoActivoByToken resuelve el servicio a partir del token
|
||||
// crudo recibido en el header Authorization. Solo hace match si está activo.
|
||||
func FindServicioPagoExternoActivoByToken(rawToken string) (*ServicioPagoExterno, error) {
|
||||
hash := HashTokenServicioPago(rawToken)
|
||||
var s ServicioPagoExterno
|
||||
if err := app.Http.Database.DB.Where("token_hash = ? AND activo = ?", hash, true).First(&s).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func UpdateServicioPagoExterno(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&ServicioPagoExterno{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeleteServicioPagoExterno(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&ServicioPagoExterno{}, id).Error
|
||||
}
|
||||
|
||||
// IPPermitida valida la IP del caller contra la lista configurada en el
|
||||
// servicio (coma-separada, admite IP exacta o CIDR). Lista vacía = sin
|
||||
// restricción de IP.
|
||||
func (s *ServicioPagoExterno) IPPermitida(ip string) bool {
|
||||
lista := strings.TrimSpace(s.IPsPermitidas)
|
||||
if lista == "" {
|
||||
return true
|
||||
}
|
||||
callerIP := net.ParseIP(ip)
|
||||
if callerIP == nil {
|
||||
return false
|
||||
}
|
||||
for _, entrada := range strings.Split(lista, ",") {
|
||||
entrada = strings.TrimSpace(entrada)
|
||||
if entrada == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(entrada, "/") {
|
||||
_, red, err := net.ParseCIDR(entrada)
|
||||
if err == nil && red.Contains(callerIP) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if net.ParseIP(entrada) != nil && net.ParseIP(entrada).Equal(callerIP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PasarelasList devuelve las pasarelas habilitadas para este servicio.
|
||||
func (s *ServicioPagoExterno) PasarelasList() []string {
|
||||
return SplitModulos(s.PasarelasHabilitadas)
|
||||
}
|
||||
|
||||
// PasarelaHabilitada indica si el servicio puede usar esa pasarela.
|
||||
func (s *ServicioPagoExterno) PasarelaHabilitada(pasarela string) bool {
|
||||
for _, p := range s.PasarelasList() {
|
||||
if p == pasarela {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── SolicitudPagoExterna ───────────────────────────────────────────────────
|
||||
|
||||
// GenerarReferenciaInterna crea una referencia única con el prefijo "extpay-"
|
||||
// que los webhooks de las pasarelas usan para distinguir estas solicitudes de
|
||||
// las de un Contrato (que usan "contrato-{id}").
|
||||
func GenerarReferenciaInterna() (string, error) {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("no se pudo generar la referencia: %w", err)
|
||||
}
|
||||
return "extpay-" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func CreateSolicitudPagoExterna(s *SolicitudPagoExterna) error {
|
||||
return app.Http.Database.DB.Create(s).Error
|
||||
}
|
||||
|
||||
func GetSolicitudPagoExternaByReferenciaInterna(ref string) (*SolicitudPagoExterna, error) {
|
||||
var s SolicitudPagoExterna
|
||||
if err := app.Http.Database.DB.Preload("Servicio").Preload("Cliente").
|
||||
Where("referencia_interna = ?", ref).First(&s).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func GetAllSolicitudesPagoExterna(limit, offset int, servicioID uint) ([]SolicitudPagoExterna, int64, error) {
|
||||
var items []SolicitudPagoExterna
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&SolicitudPagoExterna{}).Preload("Servicio").Preload("Cliente")
|
||||
if servicioID > 0 {
|
||||
db = db.Where("servicio_id = ?", servicioID)
|
||||
}
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// MarcarSolicitudPagoExternaPagada transiciona pendiente→pagado de forma
|
||||
// idempotente (igual patrón que MarcarContratoPagado): si ya estaba pagada,
|
||||
// no vuelve a disparar la notificación. Devuelve ok=true solo en la
|
||||
// transición real.
|
||||
func MarcarSolicitudPagoExternaPagada(referenciaInterna, pasarelaTxID string) (ok bool, solicitud *SolicitudPagoExterna, err error) {
|
||||
now := time.Now()
|
||||
result := app.Http.Database.DB.Model(&SolicitudPagoExterna{}).
|
||||
Where("referencia_interna = ? AND estado = ?", referenciaInterna, "pendiente").
|
||||
Updates(map[string]interface{}{
|
||||
"estado": "pagado",
|
||||
"fecha_pago": now,
|
||||
"pasarela_tx_id": pasarelaTxID,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, nil, result.Error
|
||||
}
|
||||
s, getErr := GetSolicitudPagoExternaByReferenciaInterna(referenciaInterna)
|
||||
if getErr != nil {
|
||||
return result.RowsAffected > 0, nil, getErr
|
||||
}
|
||||
return result.RowsAffected > 0, s, nil
|
||||
}
|
||||
|
||||
// MarcarCallbackEntregado registra que la notificación saliente (webhook y/o
|
||||
// Telegram) ya se intentó entregar, para no reintentar indefinidamente sin
|
||||
// visibilidad.
|
||||
func MarcarCallbackEntregado(id uint, webhookOk, telegramOk bool) error {
|
||||
updates := map[string]interface{}{}
|
||||
if webhookOk {
|
||||
updates["callback_entregado"] = true
|
||||
}
|
||||
if telegramOk {
|
||||
updates["telegram_entregado"] = true
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return app.Http.Database.DB.Model(&SolicitudPagoExterna{}).Where("id = ?", id).
|
||||
Update("callback_intentos", gorm.Expr("callback_intentos + 1")).Error
|
||||
}
|
||||
updates["callback_intentos"] = gorm.Expr("callback_intentos + 1")
|
||||
return app.Http.Database.DB.Model(&SolicitudPagoExterna{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PlantillaDocumento es la fuente de verdad de las plantillas base usadas por
|
||||
// la automatización con IA: cotización, contrato, acta de proyecto y cuenta de cobro.
|
||||
// Editar una plantilla aquí actualiza automáticamente todos los canales (Telegram,
|
||||
// chat propio, Claude directo) que generan ese tipo de documento.
|
||||
type PlantillaDocumento struct {
|
||||
gorm.Model
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:30;not null;index"` // cotizacion | contrato | acta | cuenta_cobro
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
|
||||
ContenidoHTML string `json:"contenido_html" gorm:"column:contenido_html;type:text"` // Go text/template
|
||||
Version int `json:"version" gorm:"column:version;default:1"`
|
||||
Activa bool `json:"activa" gorm:"column:activa;default:true"`
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
}
|
||||
|
||||
func (PlantillaDocumento) TableName() string { return "plantillas_documento" }
|
||||
|
||||
func GetAllPlantillasDocumento(limit, offset int, search, tipo string) ([]PlantillaDocumento, int64, error) {
|
||||
var items []PlantillaDocumento
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&PlantillaDocumento{})
|
||||
if search != "" {
|
||||
db = db.Where("nombre ILIKE ?", "%"+search+"%")
|
||||
}
|
||||
if tipo != "" {
|
||||
db = db.Where("tipo = ?", tipo)
|
||||
}
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("tipo ASC, version DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func GetPlantillaDocumentoByID(id uint) (*PlantillaDocumento, error) {
|
||||
var item PlantillaDocumento
|
||||
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// GetPlantillaDocumentoActiva retorna la plantilla activa más reciente para un tipo dado.
|
||||
// Es la que usan los endpoints de generación (cotizaciones, contratos, etc).
|
||||
func GetPlantillaDocumentoActiva(tipo string) (*PlantillaDocumento, error) {
|
||||
var item PlantillaDocumento
|
||||
if err := app.Http.Database.DB.
|
||||
Where("tipo = ? AND activa = ?", tipo, true).
|
||||
Order("version DESC").
|
||||
First(&item).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func CreatePlantillaDocumento(p PlantillaDocumento) error {
|
||||
return app.Http.Database.DB.Create(&p).Error
|
||||
}
|
||||
|
||||
func UpdatePlantillaDocumento(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&PlantillaDocumento{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeletePlantillaDocumento(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&PlantillaDocumento{}, id).Error
|
||||
}
|
||||
+26
-11
@@ -181,12 +181,27 @@ func RemovePortalAcceso(portalUserID, clienteID uint) error {
|
||||
}
|
||||
|
||||
// GetClienteIDsForPortalUser devuelve todos los clienteIDs accesibles para un portal user.
|
||||
func GetClienteIDsForPortalUser(u *PortalUser) []uint {
|
||||
isPartner := u.Rol == "partner"
|
||||
if u.Role != nil {
|
||||
isPartner = u.Role.EsPortalPartner
|
||||
// EsPartner es la ÚNICA definición de "este usuario es partner". Existe
|
||||
// porque el rol se guarda en dos lados (la columna Rol y el flag
|
||||
// EsPortalPartner del Role) y consultarlos por separado se contradice: el
|
||||
// dashboard decidía el layout con u.Rol y los proyectos con Role, así que un
|
||||
// usuario con los dos desincronizados veía proyectos de varios clientes sin
|
||||
// agrupar, o la vista agrupada vacía.
|
||||
func (u *PortalUser) EsPartner() bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
if isPartner {
|
||||
if u.Role != nil {
|
||||
return u.Role.EsPortalPartner
|
||||
}
|
||||
return u.Rol == "partner"
|
||||
}
|
||||
|
||||
func GetClienteIDsForPortalUser(u *PortalUser) []uint {
|
||||
if u == nil {
|
||||
return []uint{}
|
||||
}
|
||||
if u.EsPartner() {
|
||||
ids := make([]uint, 0, len(u.PortalAccesos))
|
||||
for _, a := range u.PortalAccesos {
|
||||
ids = append(ids, a.ClienteID)
|
||||
@@ -202,12 +217,12 @@ func GetClienteIDsForPortalUser(u *PortalUser) []uint {
|
||||
// ─── PortalPasswordResetToken ────────────────────────────────────────────────
|
||||
|
||||
type PortalPasswordResetToken struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
PortalUserID uint `gorm:"column:portal_user_id;index;not null"`
|
||||
Token string `gorm:"column:token;uniqueIndex;not null"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;not null"`
|
||||
Used bool `gorm:"column:used;default:false"`
|
||||
CreatedAt time.Time
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
PortalUserID uint `gorm:"column:portal_user_id;index;not null"`
|
||||
Token string `gorm:"column:token;uniqueIndex;not null"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;not null"`
|
||||
Used bool `gorm:"column:used;default:false"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (PortalPasswordResetToken) TableName() string { return "portal_password_reset_tokens" }
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
// El rol vive en dos lados (la columna Rol y el flag EsPortalPartner del
|
||||
// Role). Consultarlos por separado se contradecía: el dashboard elegía el
|
||||
// layout con uno y los proyectos con el otro. EsPartner es ahora la única
|
||||
// definición, y estos casos fijan cuál gana.
|
||||
func TestEsPartner(t *testing.T) {
|
||||
casos := []struct {
|
||||
nombre string
|
||||
user *PortalUser
|
||||
esperado bool
|
||||
}{
|
||||
{"nil no es partner", nil, false},
|
||||
{"sin Role, rol partner", &PortalUser{Rol: "partner"}, true},
|
||||
{"sin Role, rol cliente", &PortalUser{Rol: "cliente"}, false},
|
||||
{"con Role, el flag manda aunque el rol diga cliente",
|
||||
&PortalUser{Rol: "cliente", Role: &Roles{EsPortalPartner: true}}, true},
|
||||
{"con Role, el flag manda aunque el rol diga partner",
|
||||
&PortalUser{Rol: "partner", Role: &Roles{EsPortalPartner: false}}, false},
|
||||
}
|
||||
for _, cas := range casos {
|
||||
t.Run(cas.nombre, func(t *testing.T) {
|
||||
if got := cas.user.EsPartner(); got != cas.esperado {
|
||||
t.Errorf("EsPartner() = %v, esperaba %v", got, cas.esperado)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// El alcance del portal se deriva de EsPartner: un desajuste acá es que un
|
||||
// usuario vea proyectos de un cliente que no le corresponde.
|
||||
func TestGetClienteIDsForPortalUser(t *testing.T) {
|
||||
cid := uint(7)
|
||||
casos := []struct {
|
||||
nombre string
|
||||
user *PortalUser
|
||||
esperado []uint
|
||||
}{
|
||||
{"nil no ve nada", nil, []uint{}},
|
||||
{"cliente ve el suyo", &PortalUser{Rol: "cliente", ClienteID: &cid}, []uint{7}},
|
||||
{"cliente sin ClienteID no ve nada", &PortalUser{Rol: "cliente"}, []uint{}},
|
||||
{"partner ve los de sus accesos",
|
||||
&PortalUser{Rol: "partner", PortalAccesos: []PortalAcceso{{ClienteID: 3}, {ClienteID: 9}}},
|
||||
[]uint{3, 9}},
|
||||
{"partner sin accesos no ve nada", &PortalUser{Rol: "partner"}, []uint{}},
|
||||
}
|
||||
for _, cas := range casos {
|
||||
t.Run(cas.nombre, func(t *testing.T) {
|
||||
got := GetClienteIDsForPortalUser(cas.user)
|
||||
if len(got) != len(cas.esperado) {
|
||||
t.Fatalf("= %v, esperaba %v", got, cas.esperado)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != cas.esperado[i] {
|
||||
t.Errorf("= %v, esperaba %v", got, cas.esperado)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -327,3 +327,53 @@ func GetProyectoDocumentoByID(id uint) (*ProyectoDocumento, error) {
|
||||
func DeleteProyectoDocumento(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&ProyectoDocumento{}, id).Error
|
||||
}
|
||||
|
||||
// ProgresoPorProyecto calcula el avance de varios proyectos en UNA consulta
|
||||
// agrupada.
|
||||
//
|
||||
// El dashboard hacía esto con ActualizarProgresoProyecto en un bucle: dos
|
||||
// COUNT y un UPDATE por proyecto (3N consultas, N de ellas escrituras en un
|
||||
// GET). Y no servía para lo que se estaba renderizando: los proyectos ya
|
||||
// estaban cargados en memoria, así que el UPDATE iba a la base pero la
|
||||
// pantalla seguía mostrando el valor anterior — el avance se veía siempre un
|
||||
// render atrasado.
|
||||
//
|
||||
// Las escrituras no hacen falta: todos los caminos que tocan una fase ya
|
||||
// llaman a ActualizarProgresoProyecto (ver proyecto_controller y
|
||||
// proyecto_service). Acá solo se lee.
|
||||
func ProgresoPorProyecto(proyectoIDs []uint) map[uint]int {
|
||||
out := make(map[uint]int, len(proyectoIDs))
|
||||
if len(proyectoIDs) == 0 {
|
||||
return out
|
||||
}
|
||||
var filas []struct {
|
||||
ProyectoID uint
|
||||
Total int64
|
||||
Completados int64
|
||||
}
|
||||
app.Http.Database.DB.Model(&ProyectoFase{}).
|
||||
Select("proyecto_id, COUNT(*) AS total, SUM(CASE WHEN estado = 'completado' THEN 1 ELSE 0 END) AS completados").
|
||||
Where("proyecto_id IN ? AND deleted_at IS NULL", proyectoIDs).
|
||||
Group("proyecto_id").Scan(&filas)
|
||||
|
||||
for _, f := range filas {
|
||||
if f.Total > 0 {
|
||||
out[f.ProyectoID] = int((f.Completados * 100) / f.Total)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AplicarProgreso pone el avance recién calculado en los structs que se van a
|
||||
// renderizar. Un proyecto sin fases queda en 0, que es lo correcto: no hay
|
||||
// nada planificado todavía.
|
||||
func AplicarProgreso(proyectos []Proyecto) {
|
||||
ids := make([]uint, len(proyectos))
|
||||
for i, p := range proyectos {
|
||||
ids[i] = p.ID
|
||||
}
|
||||
progresos := ProgresoPorProyecto(ids)
|
||||
for i := range proyectos {
|
||||
proyectos[i].Progreso = progresos[proyectos[i].ID]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,26 @@ import (
|
||||
|
||||
type ProyectoTicket struct {
|
||||
gorm.Model
|
||||
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
||||
PortalUserID uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
|
||||
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
|
||||
Titulo string `json:"titulo" gorm:"column:titulo"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Estado string `json:"estado" gorm:"column:estado;default:'abierto'"` // abierto|en_progreso|resuelto|cerrado
|
||||
Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"` // baja|media|alta|urgente
|
||||
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
|
||||
ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
||||
PortalUserID *uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
|
||||
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
|
||||
EmailFrom string `json:"email_from" gorm:"column:email_from;size:255"`
|
||||
Titulo string `json:"titulo" gorm:"column:titulo"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
Estado string `json:"estado" gorm:"column:estado;default:'abierto'"` // abierto|en_progreso|resuelto|cerrado
|
||||
Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"` // baja|media|alta|urgente
|
||||
Origen string `json:"origen" gorm:"column:origen;default:'portal'"` // portal|email
|
||||
AsignadoA *uint `json:"asignado_a" gorm:"column:asignado_a;index"`
|
||||
Asignado *Users `json:"asignado" gorm:"foreignKey:AsignadoA"`
|
||||
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo que originó el ticket (dedup)
|
||||
// ClienteID se resuelve al crear el ticket a partir del remitente. Si queda
|
||||
// en nil el que escribió no está registrado: es un contacto externo, y eso
|
||||
// también es información (no hay un campo aparte para "externo", es esto).
|
||||
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
|
||||
Cliente *Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
||||
// Categoria la pone el clasificador: error | facturacion | acceso | consulta | otro
|
||||
Categoria string `json:"categoria" gorm:"column:categoria;size:40;index"`
|
||||
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
|
||||
}
|
||||
|
||||
func (ProyectoTicket) TableName() string { return "proyecto_tickets" }
|
||||
@@ -25,11 +37,12 @@ func (ProyectoTicket) TableName() string { return "proyecto_tickets" }
|
||||
|
||||
type TicketMensaje struct {
|
||||
gorm.Model
|
||||
TicketID uint `json:"ticket_id" gorm:"column:ticket_id;index"`
|
||||
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
||||
EsAdmin bool `json:"es_admin" gorm:"column:es_admin;default:false"`
|
||||
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
|
||||
LeidoPortal bool `json:"leido_portal" gorm:"column:leido_portal;default:false"`
|
||||
TicketID uint `json:"ticket_id" gorm:"column:ticket_id;index"`
|
||||
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
||||
EsAdmin bool `json:"es_admin" gorm:"column:es_admin;default:false"`
|
||||
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
|
||||
LeidoPortal bool `json:"leido_portal" gorm:"column:leido_portal;default:false"`
|
||||
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo de respuesta (dedup)
|
||||
}
|
||||
|
||||
func (TicketMensaje) TableName() string { return "ticket_mensajes" }
|
||||
@@ -39,13 +52,13 @@ func (TicketMensaje) TableName() string { return "ticket_mensajes" }
|
||||
func GetTicketsByProyecto(proyectoID uint) ([]ProyectoTicket, error) {
|
||||
var items []ProyectoTicket
|
||||
err := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID).
|
||||
Preload("Mensajes").Order("created_at DESC").Find(&items).Error
|
||||
Preload("Mensajes").Preload("Asignado").Order("created_at DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetTicketByID(id uint) (*ProyectoTicket, error) {
|
||||
var item ProyectoTicket
|
||||
err := app.Http.Database.DB.Preload("Mensajes").First(&item, id).Error
|
||||
err := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").First(&item, id).Error
|
||||
return &item, err
|
||||
}
|
||||
|
||||
@@ -62,16 +75,44 @@ func CreateTicketMensaje(m *TicketMensaje) error {
|
||||
return app.Http.Database.DB.Create(m).Error
|
||||
}
|
||||
|
||||
// EmailMessageIDYaProcesado indica si un Message-Id de correo ya generó un ticket
|
||||
// o un mensaje de ticket, para no duplicar cuando el proveedor reintenta la entrega.
|
||||
func EmailMessageIDYaProcesado(messageID string) bool {
|
||||
if messageID == "" {
|
||||
return false
|
||||
}
|
||||
var count int64
|
||||
app.Http.Database.DB.Model(&ProyectoTicket{}).Where("message_id = ?", messageID).Count(&count)
|
||||
if count > 0 {
|
||||
return true
|
||||
}
|
||||
app.Http.Database.DB.Model(&TicketMensaje{}).Where("message_id = ?", messageID).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// GetUltimoTicketAbiertoPorEmail busca el ticket de origen email más reciente y no
|
||||
// cerrado de un remitente, para enhebrar una respuesta en vez de abrir uno nuevo.
|
||||
func GetUltimoTicketAbiertoPorEmail(email string) (*ProyectoTicket, error) {
|
||||
var t ProyectoTicket
|
||||
err := app.Http.Database.DB.
|
||||
Where("email_from = ? AND origen = ? AND estado <> ?", email, "email", "cerrado").
|
||||
Order("created_at DESC").First(&t).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) {
|
||||
var items []ProyectoTicket
|
||||
err := app.Http.Database.DB.Where("portal_user_id = ?", portalUserID).
|
||||
Preload("Mensajes").Order("created_at DESC").Find(&items).Error
|
||||
Preload("Mensajes").Preload("Asignado").Order("created_at DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetAllTickets(estado string) ([]ProyectoTicket, error) {
|
||||
var items []ProyectoTicket
|
||||
db := app.Http.Database.DB.Preload("Mensajes").Order("created_at DESC")
|
||||
db := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").Preload("Cliente").Order("created_at DESC")
|
||||
if estado != "" && estado != "todos" {
|
||||
db = db.Where("estado = ?", estado)
|
||||
}
|
||||
@@ -79,6 +120,28 @@ func GetAllTickets(estado string) ([]ProyectoTicket, error) {
|
||||
return items, err
|
||||
}
|
||||
|
||||
func AssignedToUser(userID uint) error {
|
||||
return app.Http.Database.DB.Model(&ProyectoTicket{}).
|
||||
Where("asignado_a IS NULL OR asignado_a = 0").
|
||||
Update("asignado_a", userID).Error
|
||||
}
|
||||
|
||||
func CountTicketsByEstado() map[string]int64 {
|
||||
result := map[string]int64{}
|
||||
type row struct {
|
||||
Estado string
|
||||
Count int64
|
||||
}
|
||||
var rows []row
|
||||
app.Http.Database.DB.Model(&ProyectoTicket{}).
|
||||
Select("estado, count(*) as count").
|
||||
Group("estado").Find(&rows)
|
||||
for _, r := range rows {
|
||||
result[r.Estado] = r.Count
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MarkTicketMessagesReadByPortal marca como leídos todos los mensajes de admin
|
||||
// de un ticket para el portal user (cuando abre el ticket).
|
||||
func MarkTicketMessagesReadByPortal(ticketID uint) error {
|
||||
@@ -86,3 +149,18 @@ func MarkTicketMessagesReadByPortal(ticketID uint) error {
|
||||
Where("ticket_id = ? AND es_admin = true AND leido_portal = false", ticketID).
|
||||
Update("leido_portal", true).Error
|
||||
}
|
||||
|
||||
// GetClientePorEmail busca un cliente por su dirección de correo, exacta y sin
|
||||
// distinguir mayúsculas. A propósito no se busca por dominio: con gmail.com o
|
||||
// hotmail.com de por medio, adivinar por dominio ata tickets al cliente
|
||||
// equivocado.
|
||||
func GetClientePorEmail(email string) (*Cliente, error) {
|
||||
var c Cliente
|
||||
err := app.Http.Database.DB.
|
||||
Where("LOWER(email) = LOWER(?) OR LOWER(email_cc) = LOWER(?)", email, email).
|
||||
First(&c).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProyectoTicketTableName(t *testing.T) {
|
||||
if got := (ProyectoTicket{}).TableName(); got != "proyecto_tickets" {
|
||||
t.Errorf("TableName() = %q, want %q", got, "proyecto_tickets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTicketMensajeTableName(t *testing.T) {
|
||||
if got := (TicketMensaje{}).TableName(); got != "ticket_mensajes" {
|
||||
t.Errorf("TableName() = %q, want %q", got, "ticket_mensajes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProyectoTicketDefaults(t *testing.T) {
|
||||
ticket := ProyectoTicket{
|
||||
Titulo: "Test ticket",
|
||||
Descripcion: "Descripción",
|
||||
AutorNombre: "Cliente",
|
||||
}
|
||||
if ticket.Estado != "" {
|
||||
t.Errorf("Estado debe ser vacío por defecto (GORM default), got %q", ticket.Estado)
|
||||
}
|
||||
if ticket.Prioridad != "" {
|
||||
t.Errorf("Prioridad debe ser vacía por defecto (GORM default), got %q", ticket.Prioridad)
|
||||
}
|
||||
if ticket.Origen != "" {
|
||||
t.Errorf("Origen debe ser vacío por defecto (GORM default), got %q", ticket.Origen)
|
||||
}
|
||||
if ticket.ProyectoID != nil {
|
||||
t.Errorf("ProyectoID debe ser nil por defecto, got %v", ticket.ProyectoID)
|
||||
}
|
||||
if ticket.PortalUserID != nil {
|
||||
t.Errorf("PortalUserID debe ser nil por defecto, got %v", ticket.PortalUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProyectoTicketEmailFrom(t *testing.T) {
|
||||
ticket := ProyectoTicket{
|
||||
EmailFrom: "cliente@ejemplo.com",
|
||||
AutorNombre: "Cliente",
|
||||
Titulo: "Soporte",
|
||||
Origen: "email",
|
||||
}
|
||||
if ticket.EmailFrom != "cliente@ejemplo.com" {
|
||||
t.Errorf("EmailFrom = %q, want %q", ticket.EmailFrom, "cliente@ejemplo.com")
|
||||
}
|
||||
if ticket.Origen != "email" {
|
||||
t.Errorf("Origen = %q, want %q", ticket.Origen, "email")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProyectoTicketAsignado(t *testing.T) {
|
||||
uid := uint(42)
|
||||
ticket := ProyectoTicket{
|
||||
AsignadoA: &uid,
|
||||
}
|
||||
if ticket.AsignadoA == nil {
|
||||
t.Fatal("AsignadoA debe ser no nil")
|
||||
}
|
||||
if *ticket.AsignadoA != 42 {
|
||||
t.Errorf("AsignadoA = %d, want %d", *ticket.AsignadoA, 42)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProyectoTicketAsignadoNil(t *testing.T) {
|
||||
ticket := ProyectoTicket{}
|
||||
if ticket.AsignadoA != nil {
|
||||
t.Errorf("AsignadoA debe ser nil por defecto, got %v", *ticket.AsignadoA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTicketMensajeDefaults(t *testing.T) {
|
||||
msg := TicketMensaje{
|
||||
TicketID: 1,
|
||||
Contenido: "Hola",
|
||||
AutorNombre: "Admin",
|
||||
}
|
||||
if msg.EsAdmin {
|
||||
t.Error("EsAdmin debe ser false por defecto")
|
||||
}
|
||||
if msg.LeidoPortal {
|
||||
t.Error("LeidoPortal debe ser false por defecto")
|
||||
}
|
||||
}
|
||||
|
||||
// CountTicketsByEstado requiere DB real — probado en integration tests
|
||||
@@ -12,6 +12,7 @@ type QueryHistory struct {
|
||||
gorm.Model
|
||||
ConxDbID uint `json:"conx_db_id" gorm:"column:conx_db_id;index"`
|
||||
ConxDb ConxDb `json:"conx_db" gorm:"foreignKey:ConxDbID"`
|
||||
UserID uint `json:"user_id" gorm:"column:user_id;index;default:0"`
|
||||
SQL string `json:"sql" gorm:"column:sql;type:text"`
|
||||
Status string `json:"status" gorm:"column:status"` // ok | error
|
||||
ErrorMsg string `json:"error_msg" gorm:"column:error_msg;type:text"`
|
||||
@@ -29,8 +30,23 @@ func SaveQueryHistory(h QueryHistory) error {
|
||||
return app.Http.Database.DB.Create(&h).Error
|
||||
}
|
||||
|
||||
// GetQueryHistory devuelve el historial de una conexión con paginación.
|
||||
func GetQueryHistory(conxDbID uint, limit, offset int) ([]QueryHistory, int64, error) {
|
||||
// GetQueryHistory devuelve el historial de una conexión con paginación, filtrado por usuario.
|
||||
func GetQueryHistory(conxDbID, userID uint, limit, offset int) ([]QueryHistory, int64, error) {
|
||||
var items []QueryHistory
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&QueryHistory{}).Where("conx_db_id = ? AND user_id = ?", conxDbID, userID)
|
||||
db.Count(&total)
|
||||
err := db.Order("executed_at DESC").Limit(limit).Offset(offset).Find(&items).Error
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
// DeleteQueryHistory elimina el historial de una conexión para un usuario.
|
||||
func DeleteQueryHistory(conxDbID, userID uint) error {
|
||||
return app.Http.Database.DB.Where("conx_db_id = ? AND user_id = ?", conxDbID, userID).Delete(&QueryHistory{}).Error
|
||||
}
|
||||
|
||||
// GetQueryHistoryAdmin devuelve todo el historial de una conexión (solo admin).
|
||||
func GetQueryHistoryAdmin(conxDbID uint, limit, offset int) ([]QueryHistory, int64, error) {
|
||||
var items []QueryHistory
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&QueryHistory{}).Where("conx_db_id = ?", conxDbID)
|
||||
@@ -39,7 +55,7 @@ func GetQueryHistory(conxDbID uint, limit, offset int) ([]QueryHistory, int64, e
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
// DeleteQueryHistory elimina todo el historial de una conexión.
|
||||
func DeleteQueryHistory(conxDbID uint) error {
|
||||
// DeleteQueryHistoryAdmin elimina todo el historial de una conexión (solo admin).
|
||||
func DeleteQueryHistoryAdmin(conxDbID uint) error {
|
||||
return app.Http.Database.DB.Where("conx_db_id = ?", conxDbID).Delete(&QueryHistory{}).Error
|
||||
}
|
||||
|
||||
+10
-1
@@ -17,6 +17,7 @@ type Roles struct {
|
||||
EsPortalCliente bool `json:"es_portal_cliente" gorm:"column:es_portal_cliente;default:false"`
|
||||
EsPortalPartner bool `json:"es_portal_partner" gorm:"column:es_portal_partner;default:false"`
|
||||
Submodules []Submodules `json:"submodules" gorm:"many2many:roles_submodules"`
|
||||
ConxDBs []ConxDb `json:"conx_dbs" gorm:"many2many:roles_conx_db"`
|
||||
}
|
||||
|
||||
// TableName overrides the table name used by Modules to `modules`
|
||||
@@ -41,7 +42,7 @@ func AllRoles(limit, offset int, search string) ([]Roles, int64, error) {
|
||||
}
|
||||
|
||||
// Obtener los módulos con paginación
|
||||
if err := db.Order("id DESC").Limit(limit).Offset(offset).Preload("Submodules").Find(&roles).Error; err != nil {
|
||||
if err := db.Order("id DESC").Limit(limit).Offset(offset).Preload("Submodules").Preload("ConxDBs").Find(&roles).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -62,6 +63,14 @@ func AllRolesSelect() ([]Roles, error) {
|
||||
|
||||
// CreateModule creates a new rol
|
||||
func CreateRole(role Roles) error {
|
||||
var conxDbs []ConxDb
|
||||
for _, db := range role.ConxDBs {
|
||||
var found ConxDb
|
||||
if err := app.Http.Database.DB.First(&found, db.ID).Error; err == nil {
|
||||
conxDbs = append(conxDbs, found)
|
||||
}
|
||||
}
|
||||
role.ConxDBs = conxDbs
|
||||
if err := app.Http.Database.DB.Create(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SoporteWebhookConfig struct {
|
||||
gorm.Model
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:100"`
|
||||
Provider string `json:"provider" gorm:"column:provider;size:50;default:'sendgrid'"` // sendgrid|mailgun|generic
|
||||
ApiKey string `json:"api_key" gorm:"column:api_key;size:255"`
|
||||
EmailDestino string `json:"email_destino" gorm:"column:email_destino;size:255"` // ej: soporte@u-s.app
|
||||
ResponderAuto bool `json:"responder_auto" gorm:"column:responder_auto;default:true"`
|
||||
MensajeAuto string `json:"mensaje_auto" gorm:"column:mensaje_auto;type:text"`
|
||||
AsignarA *uint `json:"asignar_a" gorm:"column:asignar_a;index"` // auto-asignar tickets a este user
|
||||
|
||||
// SMTP salida para notificaciones y auto-respuesta
|
||||
SmtpHost string `json:"smtp_host" gorm:"column:smtp_host;size:255"`
|
||||
SmtpPort int `json:"smtp_port" gorm:"column:smtp_port;default:587"`
|
||||
SmtpUsername string `json:"smtp_username" gorm:"column:smtp_username;size:255"`
|
||||
SmtpPassword string `json:"smtp_password" gorm:"column:smtp_password;size:255"` // cifrado AES
|
||||
SmtpEncryption string `json:"smtp_encryption" gorm:"column:smtp_encryption;size:20;default:'tls'"` // tls|starttls|none
|
||||
SmtpFromAddr string `json:"smtp_from_addr" gorm:"column:smtp_from_addr;size:255"`
|
||||
SmtpFromName string `json:"smtp_from_name" gorm:"column:smtp_from_name;size:255"`
|
||||
|
||||
// IMAP entrante: leer el buzón directamente en vez de depender de que un
|
||||
// proveedor nos haga POST. Es lo único que hace falta para responderle a un
|
||||
// cliente que escribe a soporte@ desde su correo de siempre.
|
||||
ImapActivo bool `json:"imap_activo" gorm:"column:imap_activo;default:false"`
|
||||
ImapHost string `json:"imap_host" gorm:"column:imap_host;size:255"`
|
||||
ImapPort int `json:"imap_port" gorm:"column:imap_port;default:993"`
|
||||
ImapUsername string `json:"imap_username" gorm:"column:imap_username;size:255"`
|
||||
ImapPasswordEnc string `json:"-" gorm:"column:imap_password_enc;type:text"` // AES-GCM con APP_KEY
|
||||
ImapEncryption string `json:"imap_encryption" gorm:"column:imap_encryption;size:20;default:'ssl'"` // ssl|starttls
|
||||
ImapCarpeta string `json:"imap_carpeta" gorm:"column:imap_carpeta;size:100;default:'INBOX'"`
|
||||
// Ventana hacia atrás, en horas: solo se leen los correos recibidos dentro
|
||||
// de ella. 0 = sin límite (todo el buzón sin leer). Es lo que evita que la
|
||||
// primera corrida se coma años de correo viejo.
|
||||
ImapHorasAtras int `json:"imap_horas_atras" gorm:"column:imap_horas_atras;default:12"`
|
||||
|
||||
// Filtro con IA: no todo lo que llega al buzón es soporte (newsletters,
|
||||
// notificaciones de bancos, facturas de proveedores). Si está prendido, se
|
||||
// clasifica cada correo nuevo antes de abrir ticket.
|
||||
ClasificarConIA bool `json:"clasificar_con_ia" gorm:"column:clasificar_con_ia;default:false"`
|
||||
ContextoNegocio string `json:"contexto_negocio" gorm:"column:contexto_negocio;type:text"`
|
||||
// Agente de uMind cuya base de conocimiento se usa para redactar borradores
|
||||
// de respuesta. Sin agente el borrador se arma solo con la conversación.
|
||||
AgenteBorradorID *uint `json:"agente_borrador_id" gorm:"column:agente_borrador_id;index"`
|
||||
|
||||
// Solo para la vista: dice si ya hay contraseña guardada sin exponerla, para
|
||||
// que el formulario sepa que puede mandar el campo vacío sin borrarla.
|
||||
TieneImapPassword bool `json:"tiene_imap_password" gorm:"-"`
|
||||
}
|
||||
|
||||
func (SoporteWebhookConfig) TableName() string { return "soporte_webhook_config" }
|
||||
|
||||
func GetSoporteWebhookActivo() (*SoporteWebhookConfig, error) {
|
||||
var item SoporteWebhookConfig
|
||||
err := app.Http.Database.DB.Where("activo = ?", true).First(&item).Error
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func GetAllSoporteWebhookConfigs() ([]SoporteWebhookConfig, error) {
|
||||
var items []SoporteWebhookConfig
|
||||
err := app.Http.Database.DB.Order("created_at DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func SaveSoporteWebhookConfig(s *SoporteWebhookConfig) error {
|
||||
if s.ID > 0 {
|
||||
// Select("*") para que los booleanos en false (activo, responder_auto,
|
||||
// imap_activo) también se guarden: Updates con struct ignora los ceros,
|
||||
// así que desactivar algo no tenía efecto.
|
||||
return app.Http.Database.DB.Model(&SoporteWebhookConfig{}).
|
||||
Where("id = ?", s.ID).
|
||||
Select("*").Omit("id", "created_at", "deleted_at").
|
||||
Updates(s).Error
|
||||
}
|
||||
return app.Http.Database.DB.Create(s).Error
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSoporteWebhookConfigTableName(t *testing.T) {
|
||||
if got := (SoporteWebhookConfig{}).TableName(); got != "soporte_webhook_config" {
|
||||
t.Errorf("TableName() = %q, want %q", got, "soporte_webhook_config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoporteWebhookConfigDefaults(t *testing.T) {
|
||||
cfg := SoporteWebhookConfig{
|
||||
Nombre: "Test",
|
||||
Provider: "sendgrid",
|
||||
}
|
||||
if cfg.Provider != "sendgrid" {
|
||||
t.Errorf("Provider = %q, want %q", cfg.Provider, "sendgrid")
|
||||
}
|
||||
if cfg.EmailDestino != "" {
|
||||
t.Errorf("EmailDestino debe ser vacío, got %q", cfg.EmailDestino)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoporteWebhookConfigAutoResponder(t *testing.T) {
|
||||
cfg := SoporteWebhookConfig{
|
||||
Nombre: "Soporte",
|
||||
ResponderAuto: true,
|
||||
MensajeAuto: "Gracias por contactarnos",
|
||||
}
|
||||
if !cfg.ResponderAuto {
|
||||
t.Error("ResponderAuto debe ser true")
|
||||
}
|
||||
if cfg.MensajeAuto != "Gracias por contactarnos" {
|
||||
t.Errorf("MensajeAuto = %q, want %q", cfg.MensajeAuto, "Gracias por contactarnos")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoporteWebhookConfigAsignarA(t *testing.T) {
|
||||
uid := uint(5)
|
||||
cfg := SoporteWebhookConfig{
|
||||
AsignarA: &uid,
|
||||
}
|
||||
if cfg.AsignarA == nil {
|
||||
t.Fatal("AsignarA debe ser no nil")
|
||||
}
|
||||
if *cfg.AsignarA != 5 {
|
||||
t.Errorf("AsignarA = %d, want %d", *cfg.AsignarA, 5)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoporteWebhookConfigAsignarANil(t *testing.T) {
|
||||
cfg := SoporteWebhookConfig{}
|
||||
if cfg.AsignarA != nil {
|
||||
t.Errorf("AsignarA debe ser nil por defecto, got %v", *cfg.AsignarA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoporteWebhookConfigSmtpDefaults(t *testing.T) {
|
||||
cfg := SoporteWebhookConfig{Nombre: "SMTP Test"}
|
||||
// Go zero values — los defaults reales los aplica GORM o el controller
|
||||
if cfg.SmtpHost != "" {
|
||||
t.Errorf("SmtpHost debe ser vacío por defecto, got %q", cfg.SmtpHost)
|
||||
}
|
||||
if cfg.SmtpPort != 0 {
|
||||
t.Errorf("SmtpPort debe ser 0 en Go, got %d", cfg.SmtpPort)
|
||||
}
|
||||
}
|
||||
@@ -82,3 +82,14 @@ func DeleteSumodule(id uint) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllSubmodulesConModulo devuelve todos los submódulos con su módulo cargado.
|
||||
//
|
||||
// Lo usa el menú para los administradores: como entran a todas las rutas,
|
||||
// tienen que ver todos los enlaces. Con Preload porque el menú agrupa por el
|
||||
// título del módulo, y sin él quedaría todo bajo un grupo vacío.
|
||||
func AllSubmodulesConModulo() ([]Submodules, error) {
|
||||
var items []Submodules
|
||||
err := app.Http.Database.DB.Preload("Module").Order("id ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
@@ -23,6 +23,30 @@ type Tarea struct {
|
||||
|
||||
func (Tarea) TableName() string { return "tarea" }
|
||||
|
||||
// RepararEstadosTareaInvalidos corrige tareas que quedaron con un estado que el
|
||||
// tablero Kanban no reconoce (columnas válidas: por_hacer, en_progreso, revision,
|
||||
// hecho). El agente de Telegram creó tareas con "pendiente"/"completada"/
|
||||
// "cancelada" antes de esta corrección: se guardaban bien en la base de datos
|
||||
// pero no aparecían en ninguna columna del listado. Es idempotente — una vez
|
||||
// corregidas, no vuelve a tocarlas.
|
||||
func RepararEstadosTareaInvalidos() (int64, error) {
|
||||
db := app.Http.Database.DB
|
||||
var total int64
|
||||
mapa := map[string]string{
|
||||
"pendiente": "por_hacer",
|
||||
"completada": "hecho",
|
||||
"cancelada": "hecho",
|
||||
}
|
||||
for viejo, nuevo := range mapa {
|
||||
result := db.Model(&Tarea{}).Where("estado = ?", viejo).Update("estado", nuevo)
|
||||
if result.Error != nil {
|
||||
return total, result.Error
|
||||
}
|
||||
total += result.RowsAffected
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
type TareaComentario struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
TareaID uint `json:"tarea_id" gorm:"column:tarea_id;index"`
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Tarifa es la tabla de precios/reglas de negocio que la IA usa para calcular
|
||||
// cotizaciones y contratos: valor por hora según tipo de servicio, licencias M365,
|
||||
// tipos de VM Azure recurrentes, márgenes estándar, etc.
|
||||
type Tarifa struct {
|
||||
gorm.Model
|
||||
Categoria string `json:"categoria" gorm:"column:categoria;size:50;not null;index"` // hora_servicio | licencia | vm_azure | margen | otro
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
|
||||
Valor float64 `json:"valor" gorm:"column:valor;not null"`
|
||||
Moneda string `json:"moneda" gorm:"column:moneda;size:10;default:'COP'"`
|
||||
Unidad string `json:"unidad" gorm:"column:unidad;size:20"` // hora | mes | unico | porcentaje
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (Tarifa) TableName() string { return "tarifas" }
|
||||
|
||||
func GetAllTarifas(limit, offset int, search, categoria string) ([]Tarifa, int64, error) {
|
||||
var items []Tarifa
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&Tarifa{})
|
||||
if search != "" {
|
||||
db = db.Where("nombre ILIKE ?", "%"+search+"%")
|
||||
}
|
||||
if categoria != "" {
|
||||
db = db.Where("categoria = ?", categoria)
|
||||
}
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("categoria ASC, nombre ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// GetTarifasActivas retorna todas las tarifas activas, opcionalmente filtradas por categoría.
|
||||
// Es lo que usan los endpoints de generación para calcular precios.
|
||||
func GetTarifasActivas(categoria string) ([]Tarifa, error) {
|
||||
var items []Tarifa
|
||||
db := app.Http.Database.DB.Where("activo = ?", true)
|
||||
if categoria != "" {
|
||||
db = db.Where("categoria = ?", categoria)
|
||||
}
|
||||
if err := db.Order("nombre ASC").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func GetTarifaByID(id uint) (*Tarifa, error) {
|
||||
var item Tarifa
|
||||
if err := app.Http.Database.DB.First(&item, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func CreateTarifa(t Tarifa) error {
|
||||
return app.Http.Database.DB.Create(&t).Error
|
||||
}
|
||||
|
||||
func UpdateTarifa(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&Tarifa{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeleteTarifa(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&Tarifa{}, id).Error
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TelegramAgentHistory guarda el historial de conversación por chat_id.
|
||||
// Se usan los últimos N mensajes como contexto en cada llamada al AI.
|
||||
type TelegramAgentHistory struct {
|
||||
gorm.Model
|
||||
ChatID int64 `json:"chat_id" gorm:"column:chat_id;index;not null"`
|
||||
Role string `json:"role" gorm:"column:role;not null"` // user | assistant | tool
|
||||
Content string `json:"content" gorm:"column:content;type:text;not null"`
|
||||
// ToolName y ToolResult se usan para mensajes de tipo "tool"
|
||||
ToolName string `json:"tool_name" gorm:"column:tool_name"`
|
||||
ToolResult string `json:"tool_result" gorm:"column:tool_result;type:text"`
|
||||
}
|
||||
|
||||
func (TelegramAgentHistory) TableName() string { return "telegram_agent_history" }
|
||||
|
||||
// TelegramAgentChatID guarda qué chat_ids están autorizados a usar el agente.
|
||||
type TelegramAgentAuth struct {
|
||||
gorm.Model
|
||||
ChatID int64 `json:"chat_id" gorm:"column:chat_id;uniqueIndex;not null"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (TelegramAgentAuth) TableName() string { return "telegram_agent_auth" }
|
||||
|
||||
// GetAgentHistory retorna los últimos n mensajes del historial para un chat_id.
|
||||
func GetAgentHistory(chatID int64, n int) ([]TelegramAgentHistory, error) {
|
||||
var items []TelegramAgentHistory
|
||||
err := app.Http.Database.DB.
|
||||
Where("chat_id = ?", chatID).
|
||||
Order("created_at DESC").
|
||||
Limit(n).
|
||||
Find(&items).Error
|
||||
// Invertir para orden cronológico
|
||||
for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 {
|
||||
items[i], items[j] = items[j], items[i]
|
||||
}
|
||||
return items, err
|
||||
}
|
||||
|
||||
func SaveAgentMessage(chatID int64, role, content, toolName, toolResult string) error {
|
||||
msg := &TelegramAgentHistory{
|
||||
ChatID: chatID,
|
||||
Role: role,
|
||||
Content: content,
|
||||
ToolName: toolName,
|
||||
ToolResult: toolResult,
|
||||
}
|
||||
return app.Http.Database.DB.Create(msg).Error
|
||||
}
|
||||
|
||||
// ClearAgentHistory borra el historial de un chat (comando /reset).
|
||||
func ClearAgentHistory(chatID int64) error {
|
||||
return app.Http.Database.DB.Where("chat_id = ?", chatID).Delete(&TelegramAgentHistory{}).Error
|
||||
}
|
||||
|
||||
// IsAgentAuthChat verifica si un chat_id está autorizado.
|
||||
func IsAgentAuthChat(chatID int64) bool {
|
||||
var auth TelegramAgentAuth
|
||||
err := app.Http.Database.DB.Where("chat_id = ? AND activo = ?", chatID, true).First(&auth).Error
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func GetAllAgentAuth() ([]TelegramAgentAuth, error) {
|
||||
var items []TelegramAgentAuth
|
||||
err := app.Http.Database.DB.Order("created_at DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func CreateAgentAuth(chatID int64, nombre string) error {
|
||||
auth := &TelegramAgentAuth{ChatID: chatID, Nombre: nombre, Activo: true}
|
||||
return app.Http.Database.DB.Create(auth).Error
|
||||
}
|
||||
|
||||
func DeleteAgentAuth(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&TelegramAgentAuth{}, id).Error
|
||||
}
|
||||
|
||||
// GetAgentTelegramConfig retorna el TelegramConfig vinculado al agente bot activo.
|
||||
func GetAgentTelegramConfig() (*TelegramConfig, error) {
|
||||
var ai AiConfig
|
||||
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil {
|
||||
return nil, fmt.Errorf("no hay agente bot configurado")
|
||||
}
|
||||
if ai.TelegramConfigID == nil {
|
||||
return nil, fmt.Errorf("el agente no tiene Telegram configurado")
|
||||
}
|
||||
return GetTelegramConfigByID(*ai.TelegramConfigID)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TelegramStaffToken almacena un código de verificación temporal para que un
|
||||
// usuario interno (staff) vincule su Telegram y reciba notificaciones personales
|
||||
// (ej: tareas asignadas), igual que TelegramPortalToken pero para Users.
|
||||
type TelegramStaffToken struct {
|
||||
gorm.Model
|
||||
UserID uint `json:"user_id" gorm:"column:user_id;uniqueIndex"`
|
||||
Token string `json:"token" gorm:"column:token;uniqueIndex;size:8"`
|
||||
}
|
||||
|
||||
func (TelegramStaffToken) TableName() string { return "telegram_staff_tokens" }
|
||||
|
||||
// GenerateTelegramStaffToken genera (o renueva) el código de vinculación para el usuario.
|
||||
func GenerateTelegramStaffToken(userID uint) (*TelegramStaffToken, error) {
|
||||
app.Http.Database.DB.Unscoped().Where("user_id = ?", userID).Delete(&TelegramStaffToken{})
|
||||
|
||||
b := make([]byte, 3)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, fmt.Errorf("no se pudo generar token: %w", err)
|
||||
}
|
||||
token := fmt.Sprintf("%06X", b)
|
||||
|
||||
t := &TelegramStaffToken{UserID: userID, Token: token}
|
||||
if err := app.Http.Database.DB.Create(t).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// GetTelegramStaffTokenByUser devuelve el token vigente de un usuario interno.
|
||||
func GetTelegramStaffTokenByUser(userID uint) (*TelegramStaffToken, error) {
|
||||
var t TelegramStaffToken
|
||||
err := app.Http.Database.DB.Where("user_id = ?", userID).First(&t).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// DeleteTelegramStaffToken elimina el token de un usuario (tras vincular correctamente).
|
||||
func DeleteTelegramStaffToken(userID uint) {
|
||||
app.Http.Database.DB.Unscoped().Where("user_id = ?", userID).Delete(&TelegramStaffToken{})
|
||||
}
|
||||
|
||||
// UpdateUserTelegramChatID vincula el chat_id de Telegram al usuario interno.
|
||||
func UpdateUserTelegramChatID(userID uint, chatID string) error {
|
||||
return app.Http.Database.DB.Model(&Users{}).Where("id = ?", userID).
|
||||
Update("telegram_chat_id", chatID).Error
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UmindTenant representa un sitio/cliente de uMind — dueño de los dominios
|
||||
// permitidos y el nombre del negocio que se muestra al visitante. Un tenant
|
||||
// puede tener varios UmindAgente independientes (cada uno con su propia
|
||||
// config de IA, base de conocimiento, tools y canales); lo que antes vivía
|
||||
// acá (SiteKey, AiConfigID, Tono, MensajeBienvenida, Color) se movió a
|
||||
// UmindAgente — ver pkg/models/umind_agente.go y migrations.MigrarUmindAgentes.
|
||||
type UmindTenant struct {
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
|
||||
DominiosPermitidos string `json:"dominios_permitidos" gorm:"column:dominios_permitidos;type:text"` // coma-separado, ej: u-site.app,www.u-site.app
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
|
||||
// ClienteID es lo que le permite a un PortalUser llegar a sus tenants
|
||||
// (ver GetClienteIDsForPortalUser). Puntero y sin not null: los tenants
|
||||
// creados antes de que existiera el portal quedan sin cliente hasta que
|
||||
// el staff los asigne, y el ALTER TABLE no falla sobre datos existentes.
|
||||
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
|
||||
PlanID *uint `json:"plan_id" gorm:"column:plan_id;index"`
|
||||
// UltimoAvisoTope guarda el período ("2026-08") en que se avisó que se
|
||||
// pasó el tope de consumo, para no repetirlo por cada mensaje. En la base
|
||||
// y no en memoria: con despliegues varias veces al día, un flag en RAM se
|
||||
// borra seguido y el cliente recibe el mismo aviso una y otra vez.
|
||||
UltimoAvisoTope string `json:"ultimo_aviso_tope" gorm:"column:ultimo_aviso_tope;size:7"`
|
||||
}
|
||||
|
||||
// MarcarAvisoTope deja registrado que ya se avisó en ese período. Devuelve
|
||||
// false si el aviso ya estaba puesto, que es la señal de no volver a mandarlo.
|
||||
//
|
||||
// El UPDATE condicionado es lo que hace la operación atómica: si dos mensajes
|
||||
// cruzan el tope a la vez, sólo uno afecta una fila y sólo ese avisa.
|
||||
func MarcarAvisoTope(tenantID uint, periodo string) bool {
|
||||
res := app.Http.Database.DB.Model(&UmindTenant{}).
|
||||
Where("id = ? AND (ultimo_aviso_tope IS NULL OR ultimo_aviso_tope <> ?)", tenantID, periodo).
|
||||
Update("ultimo_aviso_tope", periodo)
|
||||
return res.Error == nil && res.RowsAffected > 0
|
||||
}
|
||||
|
||||
func (UmindTenant) TableName() string { return "umind_tenants" }
|
||||
|
||||
// GenerarSiteKey crea un identificador público único para el widget de un
|
||||
// agente. No se hashea (a diferencia de un token de API) porque no es un
|
||||
// secreto: viaja en el HTML público del sitio del cliente.
|
||||
func GenerarSiteKey() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("no se pudo generar la site_key: %w", err)
|
||||
}
|
||||
return "umk_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func CreateUmindTenant(t *UmindTenant) error {
|
||||
return app.Http.Database.DB.Create(t).Error
|
||||
}
|
||||
|
||||
func GetAllUmindTenants(limit, offset int) ([]UmindTenant, int64, error) {
|
||||
var items []UmindTenant
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&UmindTenant{})
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// GetUmindTenantsByClientes lista los tenants que pertenecen a alguno de los
|
||||
// clientes dados — es la base del alcance del portal. Lista vacía devuelve
|
||||
// vacío sin consultar (fail-closed: un portal user sin clientes no ve nada).
|
||||
func GetUmindTenantsByClientes(clienteIDs []uint) ([]UmindTenant, error) {
|
||||
if len(clienteIDs) == 0 {
|
||||
return []UmindTenant{}, nil
|
||||
}
|
||||
var items []UmindTenant
|
||||
err := app.Http.Database.DB.Where("cliente_id IN ?", clienteIDs).Order("id DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetUmindTenantByID(id uint) (*UmindTenant, error) {
|
||||
var t UmindTenant
|
||||
if err := app.Http.Database.DB.First(&t, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func UpdateUmindTenant(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&UmindTenant{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeleteUmindTenant(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&UmindTenant{}, id).Error
|
||||
}
|
||||
|
||||
// DominioPermitido valida el host de un Origin/Referer contra la lista
|
||||
// configurada. Admite dominio exacto o comodín "*.dominio.com" para
|
||||
// subdominios. Lista vacía = no permite nada (fail-closed): un tenant recién
|
||||
// creado sin dominios configurados no debe poder ser usado desde ningún sitio.
|
||||
func (t *UmindTenant) DominioPermitido(host string) bool {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
lista := strings.TrimSpace(t.DominiosPermitidos)
|
||||
if lista == "" {
|
||||
return false
|
||||
}
|
||||
for _, entrada := range strings.Split(lista, ",") {
|
||||
entrada = strings.ToLower(strings.TrimSpace(entrada))
|
||||
if entrada == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(entrada, "*.") {
|
||||
sufijo := entrada[1:] // ".dominio.com"
|
||||
if strings.HasSuffix(host, sufijo) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if entrada == host {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Documentos y chunks de conocimiento ────────────────────────────────────
|
||||
|
||||
// UmindDocumento es una fuente de conocimiento de un agente: una URL
|
||||
// crawleada o un archivo subido. Se trocea en UmindChunk para la búsqueda
|
||||
// por similitud.
|
||||
type UmindDocumento struct {
|
||||
gorm.Model
|
||||
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // url | archivo | texto
|
||||
Origen string `json:"origen" gorm:"column:origen;type:text"` // la URL crawleada, el nombre del archivo, o el título del texto
|
||||
Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente | procesando | listo | error
|
||||
Error string `json:"error" gorm:"column:error;type:text"`
|
||||
TotalChunks int `json:"total_chunks" gorm:"column:total_chunks;default:0"`
|
||||
// Contenido guarda el texto de las fuentes que no se pueden volver a
|
||||
// buscar solas (lo que escribió el dueño, lo que se extrajo de un archivo).
|
||||
// Sin esto no se puede editar ni reprocesar sin volver a subir el archivo.
|
||||
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
||||
// MaxPaginas se guarda para poder recrawlear igual que la primera vez.
|
||||
MaxPaginas int `json:"max_paginas" gorm:"column:max_paginas;default:0"`
|
||||
// ProcesadoAt dice de cuándo es el conocimiento. Una web cambia y el agente
|
||||
// sigue contestando lo viejo con total seguridad: esta fecha es lo único
|
||||
// que delata que la fuente quedó vieja.
|
||||
ProcesadoAt *time.Time `json:"procesado_at" gorm:"column:procesado_at"`
|
||||
// AutoActualizar deja que el cron la vuelva a procesar sola.
|
||||
AutoActualizar bool `json:"auto_actualizar" gorm:"column:auto_actualizar;default:false"`
|
||||
}
|
||||
|
||||
// NombresDeTenantsUmind devuelve id → nombre para poder etiquetar cosas que
|
||||
// solo guardan el id. Una consulta y no una por fila: son pocos y se usan para
|
||||
// pintar una lista entera.
|
||||
func NombresDeTenantsUmind() map[uint]string {
|
||||
var filas []UmindTenant
|
||||
out := map[uint]string{}
|
||||
if err := app.Http.Database.DB.Select("id, nombre").Find(&filas).Error; err != nil {
|
||||
return out
|
||||
}
|
||||
for _, f := range filas {
|
||||
out[f.ID] = f.Nombre
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (UmindDocumento) TableName() string { return "umind_documentos" }
|
||||
|
||||
func CreateUmindDocumento(d *UmindDocumento) error {
|
||||
return app.Http.Database.DB.Create(d).Error
|
||||
}
|
||||
|
||||
func GetUmindDocumentosByAgente(agenteID uint) ([]UmindDocumento, error) {
|
||||
var items []UmindDocumento
|
||||
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).Order("id DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetUmindDocumentoByID(id uint) (*UmindDocumento, error) {
|
||||
var d UmindDocumento
|
||||
if err := app.Http.Database.DB.First(&d, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func UpdateUmindDocumentoEstado(id uint, estado, errMsg string, totalChunks int) error {
|
||||
updates := map[string]interface{}{
|
||||
"estado": estado,
|
||||
"error": errMsg,
|
||||
"total_chunks": totalChunks,
|
||||
}
|
||||
if estado == "listo" {
|
||||
ahora := time.Now()
|
||||
updates["procesado_at"] = &ahora
|
||||
}
|
||||
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func UpdateUmindDocumento(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
// GetDocumentosParaRefrescar devuelve las fuentes con auto-actualización que no
|
||||
// se procesan desde hace más de los días indicados.
|
||||
func GetDocumentosParaRefrescar(dias int) ([]UmindDocumento, error) {
|
||||
var items []UmindDocumento
|
||||
corte := time.Now().AddDate(0, 0, -dias)
|
||||
err := app.Http.Database.DB.
|
||||
Where("auto_actualizar = ? AND estado <> ?", true, "procesando").
|
||||
Where("procesado_at IS NULL OR procesado_at < ?", corte).
|
||||
Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func DeleteUmindDocumento(id uint) error {
|
||||
if err := app.Http.Database.DB.Where("documento_id = ?", id).Delete(&UmindChunk{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return app.Http.Database.DB.Delete(&UmindDocumento{}, id).Error
|
||||
}
|
||||
|
||||
// UmindChunk es un fragmento de texto con su embedding, listo para búsqueda
|
||||
// por similitud. Sin pgvector por ahora: el embedding se guarda como JSON de
|
||||
// []float32 y la similitud se calcula en memoria (suficiente para el volumen
|
||||
// de un piloto de un solo agente; si el volumen crece, se migra a pgvector
|
||||
// sin cambiar la interfaz de búsqueda).
|
||||
type UmindChunk struct {
|
||||
gorm.Model
|
||||
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
||||
DocumentoID uint `json:"documento_id" gorm:"column:documento_id;index;not null"`
|
||||
Contenido string `json:"contenido" gorm:"column:contenido;type:text;not null"`
|
||||
EmbeddingJSON string `json:"-" gorm:"column:embedding_json;type:text"`
|
||||
}
|
||||
|
||||
func (UmindChunk) TableName() string { return "umind_chunks" }
|
||||
|
||||
// EmbeddingToJSON / EmbeddingFromJSON convierten el vector a/desde el formato
|
||||
// de almacenamiento en texto.
|
||||
func EmbeddingToJSON(v []float32) (string, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func EmbeddingFromJSON(s string) ([]float32, error) {
|
||||
var v []float32
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// BorrarChunksDeDocumento limpia los fragmentos de una fuente antes de volver a
|
||||
// procesarla. Sin esto, reprocesar deja la versión vieja y la nueva compitiendo
|
||||
// en la búsqueda, y la vieja puede ganar.
|
||||
func BorrarChunksDeDocumento(documentoID uint) error {
|
||||
return app.Http.Database.DB.Where("documento_id = ?", documentoID).Delete(&UmindChunk{}).Error
|
||||
}
|
||||
|
||||
func CreateUmindChunks(chunks []UmindChunk) error {
|
||||
if len(chunks) == 0 {
|
||||
return nil
|
||||
}
|
||||
return app.Http.Database.DB.CreateInBatches(chunks, 50).Error
|
||||
}
|
||||
|
||||
// GetUmindChunksByAgente retorna todos los chunks del agente, para la
|
||||
// búsqueda por similitud en memoria.
|
||||
func GetUmindChunksByAgente(agenteID uint) ([]UmindChunk, error) {
|
||||
var items []UmindChunk
|
||||
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// ─── Historial de conversación del widget ───────────────────────────────────
|
||||
|
||||
// UmindMensaje guarda el historial de conversación del widget, por agente y
|
||||
// sesión de navegador (no hay usuario autenticado del lado del visitante).
|
||||
type UmindMensaje struct {
|
||||
gorm.Model
|
||||
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
||||
SessionID string `json:"session_id" gorm:"column:session_id;index;not null"`
|
||||
Role string `json:"role" gorm:"column:role;not null"` // user | assistant
|
||||
Content string `json:"content" gorm:"column:content;type:text;not null"`
|
||||
}
|
||||
|
||||
func (UmindMensaje) TableName() string { return "umind_mensajes" }
|
||||
|
||||
func SaveUmindMensaje(agenteID uint, sessionID, role, content string) error {
|
||||
m := &UmindMensaje{AgenteID: agenteID, SessionID: sessionID, Role: role, Content: content}
|
||||
return app.Http.Database.DB.Create(m).Error
|
||||
}
|
||||
|
||||
// GetUmindHistorial retorna los últimos n mensajes de una sesión, en orden cronológico.
|
||||
func GetUmindHistorial(agenteID uint, sessionID string, n int) ([]UmindMensaje, error) {
|
||||
var items []UmindMensaje
|
||||
err := app.Http.Database.DB.
|
||||
Where("agente_id = ? AND session_id = ?", agenteID, sessionID).
|
||||
Order("created_at DESC").
|
||||
Limit(n).
|
||||
Find(&items).Error
|
||||
for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 {
|
||||
items[i], items[j] = items[j], items[i]
|
||||
}
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetUmindSesiones lista las sesiones de conversación recientes de un agente
|
||||
// (para el panel admin), con el último mensaje como resumen.
|
||||
func GetUmindSesiones(agenteID uint, limit int) ([]UmindMensaje, error) {
|
||||
var items []UmindMensaje
|
||||
err := app.Http.Database.DB.Raw(`
|
||||
SELECT * FROM (
|
||||
SELECT DISTINCT ON (session_id) *
|
||||
FROM umind_mensajes
|
||||
WHERE agente_id = ? AND deleted_at IS NULL
|
||||
ORDER BY session_id, created_at DESC
|
||||
) ultimos
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`, agenteID, limit).Scan(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// SesionResumen es una conversación vista desde el reporte: cuándo empezó,
|
||||
// cuántos mensajes tuvo y con qué la abrió el visitante. El primer mensaje es
|
||||
// lo más útil del conjunto — muestra qué le preguntan de verdad al negocio.
|
||||
type SesionResumen struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Inicio time.Time `json:"inicio"`
|
||||
Mensajes int64 `json:"mensajes"`
|
||||
PrimerMensaje string `json:"primer_mensaje"`
|
||||
}
|
||||
|
||||
// GetUmindSesionesRango agrupa los mensajes en conversaciones dentro de un
|
||||
// rango. Una sola consulta: el primer mensaje del visitante se saca con una
|
||||
// subconsulta correlacionada en vez de traer todos los mensajes y agrupar en
|
||||
// Go, que en un agente con tráfico sería traerse el historial entero.
|
||||
func GetUmindSesionesRango(agenteID uint, desde, hasta time.Time) ([]SesionResumen, error) {
|
||||
var items []SesionResumen
|
||||
err := app.Http.Database.DB.Raw(`
|
||||
SELECT
|
||||
m.session_id,
|
||||
MIN(m.created_at) AS inicio,
|
||||
COUNT(*) AS mensajes,
|
||||
COALESCE((
|
||||
SELECT p.content FROM umind_mensajes p
|
||||
WHERE p.session_id = m.session_id AND p.agente_id = m.agente_id
|
||||
AND p.role = 'user' AND p.deleted_at IS NULL
|
||||
ORDER BY p.created_at ASC LIMIT 1
|
||||
), '') AS primer_mensaje
|
||||
FROM umind_mensajes m
|
||||
WHERE m.agente_id = ? AND m.created_at >= ? AND m.created_at < ? AND m.deleted_at IS NULL
|
||||
GROUP BY m.session_id, m.agente_id
|
||||
ORDER BY inicio DESC
|
||||
LIMIT 2000
|
||||
`, agenteID, desde, hasta).Scan(&items).Error
|
||||
return items, err
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UmindAgente es un agente de IA independiente dentro de un tenant — un
|
||||
// mismo negocio puede tener varios (ej. "Ventas", "Soporte"), cada uno con
|
||||
// su propia base de conocimiento, tools, canales y conexión de correo. Lo
|
||||
// único que sigue siendo del tenant (no del agente) son los dominios
|
||||
// permitidos y el nombre del negocio que se muestra al visitante — eso es
|
||||
// del sitio, no de un agente puntual.
|
||||
type UmindAgente struct {
|
||||
gorm.Model
|
||||
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"` // etiqueta interna, ej: "Ventas"
|
||||
SiteKey string `json:"site_key" gorm:"column:site_key;uniqueIndex;size:40;not null"`
|
||||
AiConfigID *uint `json:"ai_config_id" gorm:"column:ai_config_id"`
|
||||
Tono string `json:"tono" gorm:"column:tono;type:text"`
|
||||
MensajeBienvenida string `json:"mensaje_bienvenida" gorm:"column:mensaje_bienvenida;type:text"`
|
||||
Color string `json:"color" gorm:"column:color;size:7;default:'#8eb02f'"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (UmindAgente) TableName() string { return "umind_agentes" }
|
||||
|
||||
func CreateUmindAgente(a *UmindAgente) error {
|
||||
if a.SiteKey == "" {
|
||||
key, err := GenerarSiteKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.SiteKey = key
|
||||
}
|
||||
if a.Color == "" {
|
||||
a.Color = "#8eb02f"
|
||||
}
|
||||
return app.Http.Database.DB.Create(a).Error
|
||||
}
|
||||
|
||||
func GetUmindAgentesByTenant(tenantID uint) ([]UmindAgente, error) {
|
||||
var items []UmindAgente
|
||||
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).Order("id ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetTodosLosUmindAgentes lista todos los agentes de todos los tenants. Es solo
|
||||
// para pantallas de staff que necesitan elegir uno (ej. de qué base de
|
||||
// conocimiento salen los borradores de soporte).
|
||||
func GetTodosLosUmindAgentes() ([]UmindAgente, error) {
|
||||
var items []UmindAgente
|
||||
err := app.Http.Database.DB.Order("nombre ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetUmindAgenteByID(id uint) (*UmindAgente, error) {
|
||||
var a UmindAgente
|
||||
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// GetUmindAgenteBySiteKey resuelve el agente a partir de la site_key pública
|
||||
// que manda el widget. Solo hace match si el agente está activo.
|
||||
func GetUmindAgenteBySiteKey(siteKey string) (*UmindAgente, error) {
|
||||
var a UmindAgente
|
||||
if err := app.Http.Database.DB.Where("site_key = ? AND activo = ?", siteKey, true).First(&a).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func UpdateUmindAgente(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&UmindAgente{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeleteUmindAgente(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&UmindAgente{}, id).Error
|
||||
}
|
||||
|
||||
// GetUmindAgentePorSiteKey resuelve el agente por site_key SIN filtrar por
|
||||
// activo, para que el llamador pueda distinguir "no existe" de "está
|
||||
// apagado" y decirlo. GetUmindAgenteBySiteKey (que sí filtra) queda para
|
||||
// quien solo necesita el camino feliz.
|
||||
func GetUmindAgentePorSiteKey(siteKey string) (*UmindAgente, error) {
|
||||
var a UmindAgente
|
||||
if err := app.Http.Database.DB.Where("site_key = ?", siteKey).First(&a).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// ResumenAgente son las señales que hacen falta para saber de un vistazo si
|
||||
// un agente está listo o le falta algo, sin entrar a configurarlo.
|
||||
type ResumenAgente struct {
|
||||
AgenteID uint `json:"agente_id"`
|
||||
Documentos int64 `json:"documentos"`
|
||||
Canales int64 `json:"canales"`
|
||||
Conversaciones7d int64 `json:"conversaciones_7d"`
|
||||
}
|
||||
|
||||
// GetResumenAgentes agrega las tres señales en tres consultas agrupadas, no
|
||||
// en tres por agente: con 5 agentes la diferencia no se nota, pero el patrón
|
||||
// N+1 en una pantalla de listado es el que después no se puede sacar.
|
||||
func GetResumenAgentes(agenteIDs []uint) map[uint]*ResumenAgente {
|
||||
out := make(map[uint]*ResumenAgente, len(agenteIDs))
|
||||
for _, id := range agenteIDs {
|
||||
out[id] = &ResumenAgente{AgenteID: id}
|
||||
}
|
||||
if len(agenteIDs) == 0 {
|
||||
return out
|
||||
}
|
||||
db := app.Http.Database.DB
|
||||
|
||||
type fila struct {
|
||||
AgenteID uint
|
||||
Total int64
|
||||
}
|
||||
|
||||
var docs []fila
|
||||
db.Model(&UmindDocumento{}).Select("agente_id, COUNT(*) AS total").
|
||||
Where("agente_id IN ? AND deleted_at IS NULL", agenteIDs).Group("agente_id").Scan(&docs)
|
||||
for _, f := range docs {
|
||||
if r, ok := out[f.AgenteID]; ok {
|
||||
r.Documentos = f.Total
|
||||
}
|
||||
}
|
||||
|
||||
var canales []fila
|
||||
db.Model(&UmindCanal{}).Select("agente_id, COUNT(*) AS total").
|
||||
Where("agente_id IN ? AND activo = ? AND deleted_at IS NULL", agenteIDs, true).Group("agente_id").Scan(&canales)
|
||||
for _, f := range canales {
|
||||
if r, ok := out[f.AgenteID]; ok {
|
||||
r.Canales = f.Total
|
||||
}
|
||||
}
|
||||
|
||||
// Conversaciones, no mensajes: lo que importa es a cuánta gente atendió.
|
||||
var convs []fila
|
||||
db.Model(&UmindMensaje{}).Select("agente_id, COUNT(DISTINCT session_id) AS total").
|
||||
Where("agente_id IN ? AND created_at >= ? AND deleted_at IS NULL", agenteIDs, time.Now().AddDate(0, 0, -7)).
|
||||
Group("agente_id").Scan(&convs)
|
||||
for _, f := range convs {
|
||||
if r, ok := out[f.AgenteID]; ok {
|
||||
r.Conversaciones7d = f.Total
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UmindCanal es un canal de mensajería adicional (Telegram, WhatsApp) que
|
||||
// alimenta al mismo agente del tenant que ya atiende el widget web. Los
|
||||
// secretos reales (bot token, access token de WhatsApp, etc.) viven cifrados
|
||||
// en CredencialesEnc (ver pkg/services/umind_secrets.go) — el modelo solo
|
||||
// persiste el string ya cifrado, no conoce la clave.
|
||||
//
|
||||
// WebhookSecret es un identificador público generado por nosotros, distinto
|
||||
// del secreto real del proveedor, usado SOLO para enrutar el webhook
|
||||
// entrante al canal correcto (va en la URL que se registra en
|
||||
// Telegram/Meta). Evita que el token real del proveedor termine en logs de
|
||||
// acceso o de un proxy intermedio.
|
||||
type UmindCanal struct {
|
||||
gorm.Model
|
||||
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:20;not null"` // telegram | whatsapp
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
WebhookSecret string `json:"webhook_secret" gorm:"column:webhook_secret;uniqueIndex;size:40;not null"`
|
||||
CredencialesEnc string `json:"-" gorm:"column:credenciales_enc;type:text"`
|
||||
UltimoError string `json:"ultimo_error" gorm:"column:ultimo_error;type:text"`
|
||||
// Si están activos, los mensajes de voz/audio e imágenes que llegan por
|
||||
// este canal se transcriben (Whisper) o se les extrae el texto (OCR)
|
||||
// antes de pasarlos al agente, en vez de ignorarse.
|
||||
UsarWhisperAudio bool `json:"usar_whisper_audio" gorm:"column:usar_whisper_audio;default:false"`
|
||||
UsarOcrImagenes bool `json:"usar_ocr_imagenes" gorm:"column:usar_ocr_imagenes;default:false"`
|
||||
// Documentos adjuntos (PDF, Word, texto): se les extrae el contenido y se
|
||||
// le pasa al agente como si el cliente lo hubiera escrito.
|
||||
UsarArchivosDocs bool `json:"usar_archivos_docs" gorm:"column:usar_archivos_docs;default:false"`
|
||||
}
|
||||
|
||||
func (UmindCanal) TableName() string { return "umind_canales" }
|
||||
|
||||
func GenerarWebhookSecret() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("no se pudo generar el webhook_secret: %w", err)
|
||||
}
|
||||
return "umc_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func CreateUmindCanal(c *UmindCanal) error {
|
||||
if c.WebhookSecret == "" {
|
||||
secret, err := GenerarWebhookSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.WebhookSecret = secret
|
||||
}
|
||||
return app.Http.Database.DB.Create(c).Error
|
||||
}
|
||||
|
||||
func GetUmindCanalesByAgente(agenteID uint) ([]UmindCanal, error) {
|
||||
var items []UmindCanal
|
||||
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).Order("id DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetUmindCanalByID(id uint) (*UmindCanal, error) {
|
||||
var c UmindCanal
|
||||
if err := app.Http.Database.DB.First(&c, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// GetUmindCanalByWebhookSecret resuelve el canal a partir del identificador
|
||||
// público que viene en la URL del webhook. Solo matchea si está activo.
|
||||
func GetUmindCanalByWebhookSecret(tipo, webhookSecret string) (*UmindCanal, error) {
|
||||
var c UmindCanal
|
||||
err := app.Http.Database.DB.Where("tipo = ? AND webhook_secret = ? AND activo = ?", tipo, webhookSecret, true).First(&c).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func UpdateUmindCanal(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&UmindCanal{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeleteUmindCanal(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&UmindCanal{}, id).Error
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UmindConexion es una cuenta de correo real (Gmail u Outlook) conectada por
|
||||
// OAuth a un agente, para que pueda enviar y leer correo en su nombre (tools
|
||||
// enviar_correo/leer_bandeja, ver pkg/services/umind_agent_service.go).
|
||||
// AccessTokenEnc/RefreshTokenEnc viajan cifrados en reposo (ver
|
||||
// pkg/services/umind_secrets.go) — a diferencia del site_key del widget,
|
||||
// estos SÍ son secretos: quien los tenga puede leer/mandar correo como el
|
||||
// dueño de la cuenta.
|
||||
type UmindConexion struct {
|
||||
gorm.Model
|
||||
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
||||
Proveedor string `json:"proveedor" gorm:"column:proveedor;size:20;not null"` // google | microsoft
|
||||
Email string `json:"email" gorm:"column:email;size:255"`
|
||||
AccessTokenEnc string `json:"-" gorm:"column:access_token_enc;type:text"`
|
||||
RefreshTokenEnc string `json:"-" gorm:"column:refresh_token_enc;type:text"`
|
||||
ExpiraEn time.Time `json:"expira_en" gorm:"column:expira_en"`
|
||||
Scopes string `json:"scopes" gorm:"column:scopes;type:text"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (UmindConexion) TableName() string { return "umind_conexiones" }
|
||||
|
||||
func CreateUmindConexion(c *UmindConexion) error {
|
||||
return app.Http.Database.DB.Create(c).Error
|
||||
}
|
||||
|
||||
func GetUmindConexionesByAgente(agenteID uint) ([]UmindConexion, error) {
|
||||
var items []UmindConexion
|
||||
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).Order("id DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetUmindConexionByID(id uint) (*UmindConexion, error) {
|
||||
var c UmindConexion
|
||||
if err := app.Http.Database.DB.First(&c, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// GetUmindConexionActiva retorna la primera conexión activa del agente —
|
||||
// hoy se soporta una sola cuenta de correo conectada por agente, no una
|
||||
// bandeja por proveedor a la vez.
|
||||
func GetUmindConexionActiva(agenteID uint) (*UmindConexion, error) {
|
||||
var c UmindConexion
|
||||
err := app.Http.Database.DB.Where("agente_id = ? AND activo = ?", agenteID, true).First(&c).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// DesactivarConexionesDelAgente se llama antes de crear una conexión nueva —
|
||||
// hoy se soporta una sola cuenta de correo activa por agente a la vez.
|
||||
func DesactivarConexionesDelAgente(agenteID uint) error {
|
||||
return app.Http.Database.DB.Model(&UmindConexion{}).
|
||||
Where("agente_id = ? AND activo = ?", agenteID, true).
|
||||
Update("activo", false).Error
|
||||
}
|
||||
|
||||
func UpdateUmindConexion(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&UmindConexion{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeleteUmindConexion(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&UmindConexion{}, id).Error
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UmindEventoLog es el registro de auditoría técnica de un agente — errores
|
||||
// y eventos que hoy solo quedaban en el log del servidor (invisibles desde
|
||||
// el panel): fallos al llamar al AI, tools/webhooks que fallan, canales que
|
||||
// no logran procesar un mensaje, etc. No es el historial de conversación
|
||||
// (eso es UmindMensaje) ni el estado de ingesta (eso ya se ve por
|
||||
// documento en UmindDocumento.Estado/Error) — es específicamente lo que
|
||||
// antes solo se podía ver pidiendo los logs del servidor.
|
||||
type UmindEventoLog struct {
|
||||
gorm.Model
|
||||
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
||||
Nivel string `json:"nivel" gorm:"column:nivel;size:10"` // error | warn
|
||||
Origen string `json:"origen" gorm:"column:origen;size:40"` // ai | tool | email | canal_telegram | canal_whatsapp
|
||||
Mensaje string `json:"mensaje" gorm:"column:mensaje;type:text"`
|
||||
Detalle string `json:"detalle" gorm:"column:detalle;type:text"` // JSON crudo opcional, para diagnosticar sin pedir logs
|
||||
}
|
||||
|
||||
func (UmindEventoLog) TableName() string { return "umind_eventos_log" }
|
||||
|
||||
// RegistrarEventoUmind guarda un evento de auditoría. No devuelve error a
|
||||
// propósito — es un side-channel de diagnóstico, nunca debe interrumpir el
|
||||
// flujo principal (responderle al visitante) si la escritura falla.
|
||||
func RegistrarEventoUmind(agenteID uint, nivel, origen, mensaje, detalle string) {
|
||||
e := &UmindEventoLog{AgenteID: agenteID, Nivel: nivel, Origen: origen, Mensaje: mensaje, Detalle: detalle}
|
||||
if err := app.Http.Database.DB.Create(e).Error; err != nil {
|
||||
log.Printf("[UMIND] No se pudo guardar el evento de auditoría (agente %d): %v", agenteID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetUmindEventosByAgente retorna los últimos eventos del agente, más
|
||||
// reciente primero — acotado para no crecer sin límite en la respuesta.
|
||||
func GetUmindEventosByAgente(agenteID uint, limit int) ([]UmindEventoLog, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
var items []UmindEventoLog
|
||||
err := app.Http.Database.DB.
|
||||
Where("agente_id = ?", agenteID).
|
||||
Order("id DESC").
|
||||
Limit(limit).
|
||||
Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UmindPlan define qué puede hacer un tenant y a qué precio. Los precios se
|
||||
// copian a cada UmindUso al momento de registrarlo, así que subir un precio
|
||||
// nunca revalúa consumo ya facturado ni pendiente.
|
||||
type UmindPlan struct {
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:100;not null"`
|
||||
MaxAgentes int `json:"max_agentes" gorm:"column:max_agentes;default:1"` // 0 = ilimitado
|
||||
PrecioMensual float64 `json:"precio_mensual" gorm:"column:precio_mensual;default:0"`
|
||||
PrecioPor1kTokens float64 `json:"precio_por_1k_tokens" gorm:"column:precio_por_1k_tokens;default:0"`
|
||||
PrecioPorOCR float64 `json:"precio_por_ocr" gorm:"column:precio_por_ocr;default:0"`
|
||||
PrecioPorTranscripcion float64 `json:"precio_por_transcripcion" gorm:"column:precio_por_transcripcion;default:0"`
|
||||
Moneda string `json:"moneda" gorm:"column:moneda;size:3;default:'COP'"`
|
||||
// TopeConsumoMensual solo dispara un aviso al superarse — no corta el
|
||||
// servicio. 0 = sin tope.
|
||||
TopeConsumoMensual float64 `json:"tope_consumo_mensual" gorm:"column:tope_consumo_mensual;default:0"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (UmindPlan) TableName() string { return "umind_planes" }
|
||||
|
||||
func CreateUmindPlan(p *UmindPlan) error {
|
||||
return app.Http.Database.DB.Create(p).Error
|
||||
}
|
||||
|
||||
func GetUmindPlanes() ([]UmindPlan, error) {
|
||||
var items []UmindPlan
|
||||
err := app.Http.Database.DB.Order("id ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetUmindPlanByID(id uint) (*UmindPlan, error) {
|
||||
var p UmindPlan
|
||||
if err := app.Http.Database.DB.First(&p, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func UpdateUmindPlan(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&UmindPlan{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeleteUmindPlan(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&UmindPlan{}, id).Error
|
||||
}
|
||||
|
||||
// GetPlanDeTenant devuelve el plan del tenant, o nil si no tiene uno asignado
|
||||
// (tenants viejos). Los llamadores tratan nil como "sin límites ni precios".
|
||||
func GetPlanDeTenant(tenantID uint) *UmindPlan {
|
||||
t, err := GetUmindTenantByID(tenantID)
|
||||
if err != nil || t.PlanID == nil {
|
||||
return nil
|
||||
}
|
||||
p, err := GetUmindPlanByID(*t.PlanID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UmindHerramientaMax es el máximo de tools activas por tenant — acota el
|
||||
// tamaño del prompt (cada tool declarada se manda entera al modelo en cada
|
||||
// mensaje) y la superficie de webhooks que un tenant puede disparar.
|
||||
const UmindHerramientaMax = 10
|
||||
|
||||
// UmindHerramientaParametro describe un parámetro que el modelo debe
|
||||
// completar al invocar la tool. Es un JSON Schema simplificado (solo tipos
|
||||
// primitivos) para que el staff lo pueda armar desde un formulario sin
|
||||
// escribir JSON a mano.
|
||||
type UmindHerramientaParametro struct {
|
||||
Nombre string `json:"nombre"`
|
||||
Tipo string `json:"tipo"` // string | number | boolean
|
||||
Descripcion string `json:"descripcion"`
|
||||
Requerido bool `json:"requerido"`
|
||||
}
|
||||
|
||||
// UmindHerramienta es una tool custom de un tenant: cuando el agente decide
|
||||
// usarla, se hace un POST a URL con los argumentos que decidió el modelo. El
|
||||
// valor de AuthHeaderValorEnc viaja cifrado en reposo (ver
|
||||
// pkg/services/umind_secrets.go) porque es un secreto de terceros que hay
|
||||
// que poder recuperar tal cual para reenviarlo, a diferencia de una
|
||||
// contraseña propia que solo necesitamos poder verificar.
|
||||
type UmindHerramienta struct {
|
||||
gorm.Model
|
||||
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:64;not null"` // identificador de function-calling, ej: "consultar_stock"
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text;not null"`
|
||||
ParametrosJSON string `json:"parametros_json" gorm:"column:parametros_json;type:text"` // []UmindHerramientaParametro
|
||||
URL string `json:"url" gorm:"column:url;type:text;not null"`
|
||||
AuthHeaderNombre string `json:"auth_header_nombre" gorm:"column:auth_header_nombre;size:100"` // ej: "Authorization", opcional
|
||||
AuthHeaderValorEnc string `json:"-" gorm:"column:auth_header_valor_enc;type:text"`
|
||||
Activa bool `json:"activa" gorm:"column:activa;default:true"`
|
||||
}
|
||||
|
||||
func (UmindHerramienta) TableName() string { return "umind_herramientas" }
|
||||
|
||||
func ParametrosToJSON(p []UmindHerramientaParametro) (string, error) {
|
||||
b, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func ParametrosFromJSON(s string) ([]UmindHerramientaParametro, error) {
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var p []UmindHerramientaParametro
|
||||
if err := json.Unmarshal([]byte(s), &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func CreateUmindHerramienta(h *UmindHerramienta) error {
|
||||
var activas int64
|
||||
if err := app.Http.Database.DB.Model(&UmindHerramienta{}).
|
||||
Where("agente_id = ? AND activa = ?", h.AgenteID, true).Count(&activas).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if activas >= UmindHerramientaMax {
|
||||
return fmt.Errorf("este agente ya tiene el máximo de %d tools activas", UmindHerramientaMax)
|
||||
}
|
||||
return app.Http.Database.DB.Create(h).Error
|
||||
}
|
||||
|
||||
func GetUmindHerramientasByAgente(agenteID uint) ([]UmindHerramienta, error) {
|
||||
var items []UmindHerramienta
|
||||
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).Order("id DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetUmindHerramientasActivas retorna las tools activas del agente, para
|
||||
// armar el toolset del agente en cada mensaje.
|
||||
func GetUmindHerramientasActivas(agenteID uint) ([]UmindHerramienta, error) {
|
||||
var items []UmindHerramienta
|
||||
err := app.Http.Database.DB.Where("agente_id = ? AND activa = ?", agenteID, true).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetUmindHerramientaByID(id uint) (*UmindHerramienta, error) {
|
||||
var h UmindHerramienta
|
||||
if err := app.Http.Database.DB.First(&h, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &h, nil
|
||||
}
|
||||
|
||||
// GetUmindHerramientaByNombre resuelve una tool por nombre dentro del
|
||||
// agente — así arma la llamada real cuando el modelo pide ejecutar
|
||||
// "consultar_stock", por ejemplo.
|
||||
func GetUmindHerramientaByNombre(agenteID uint, nombre string) (*UmindHerramienta, error) {
|
||||
var h UmindHerramienta
|
||||
err := app.Http.Database.DB.Where("agente_id = ? AND nombre = ? AND activa = ?", agenteID, nombre, true).First(&h).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &h, nil
|
||||
}
|
||||
|
||||
func UpdateUmindHerramienta(id uint, updates map[string]interface{}) error {
|
||||
return app.Http.Database.DB.Model(&UmindHerramienta{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func DeleteUmindHerramienta(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&UmindHerramienta{}, id).Error
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Tipos de consumo medible.
|
||||
const (
|
||||
UsoTipoIA = "ia"
|
||||
UsoTipoOCR = "ocr"
|
||||
UsoTipoWhisper = "whisper"
|
||||
)
|
||||
|
||||
// UmindUso es una línea de consumo facturable. El Costo se congela con el
|
||||
// precio vigente del plan al momento de registrarlo: subir un precio nunca
|
||||
// revalúa consumo ya ocurrido, que es lo que haría imposible defender una
|
||||
// factura ante un reclamo.
|
||||
//
|
||||
// TenantID está desnormalizado a propósito (se puede derivar del agente) para
|
||||
// poder sumar el consumo de un ciclo sin joins.
|
||||
type UmindUso struct {
|
||||
gorm.Model
|
||||
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index"`
|
||||
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:10;index"`
|
||||
Cantidad float64 `json:"cantidad" gorm:"column:cantidad"`
|
||||
Unidad string `json:"unidad" gorm:"column:unidad;size:20"`
|
||||
Costo float64 `json:"costo" gorm:"column:costo"`
|
||||
Moneda string `json:"moneda" gorm:"column:moneda;size:3"`
|
||||
// FacturadoAt null = pendiente de cobrar en el próximo ciclo.
|
||||
FacturadoAt *time.Time `json:"facturado_at" gorm:"column:facturado_at;index"`
|
||||
// CuentaPropia marca el consumo que salió por la cuenta de IA del propio
|
||||
// cliente. Se sigue midiendo —quiere ver cuánto usa su asistente— pero con
|
||||
// costo cero: ya se lo factura su proveedor, y cobrárselo también sería
|
||||
// cobrar dos veces por lo mismo.
|
||||
CuentaPropia bool `json:"cuenta_propia" gorm:"column:cuenta_propia;default:false;index"`
|
||||
}
|
||||
|
||||
func (UmindUso) TableName() string { return "umind_uso" }
|
||||
|
||||
// RegistrarUsoUmind nunca devuelve error, igual que RegistrarEventoUmind: es
|
||||
// contabilidad lateral y jamás debe tumbar la respuesta al visitante. Si
|
||||
// falla, queda en el log para reconciliar a mano.
|
||||
func RegistrarUsoUmind(agenteID uint, tipo string, cantidad float64, unidad string) {
|
||||
if agenteID == 0 || cantidad <= 0 {
|
||||
return
|
||||
}
|
||||
agente, err := GetUmindAgenteByID(agenteID)
|
||||
if err != nil {
|
||||
log.Printf("[UMIND_USO] agente %d no encontrado, no se registra el consumo: %v", agenteID, err)
|
||||
return
|
||||
}
|
||||
|
||||
plan := GetPlanDeTenant(agente.TenantID)
|
||||
moneda := "COP"
|
||||
if plan != nil {
|
||||
moneda = plan.Moneda
|
||||
}
|
||||
|
||||
// Si el agente corre sobre la cuenta de IA del propio cliente, el consumo
|
||||
// se registra igual pero no se le cobra: su proveedor ya se lo factura.
|
||||
// Solo aplica a los tokens de IA — el OCR y la transcripción son nuestros
|
||||
// servicios, los use quien los use.
|
||||
propia := tipo == UsoTipoIA && agenteUsaCuentaPropia(agente)
|
||||
|
||||
costo := 0.0
|
||||
if !propia {
|
||||
costo = costoDeUso(plan, tipo, cantidad)
|
||||
}
|
||||
|
||||
uso := &UmindUso{
|
||||
TenantID: agente.TenantID, AgenteID: agenteID,
|
||||
Tipo: tipo, Cantidad: cantidad, Unidad: unidad,
|
||||
Costo: costo, Moneda: moneda, CuentaPropia: propia,
|
||||
}
|
||||
if err := app.Http.Database.DB.Create(uso).Error; err != nil {
|
||||
log.Printf("[UMIND_USO] no se pudo registrar consumo del agente %d (%s %.2f %s): %v", agenteID, tipo, cantidad, unidad, err)
|
||||
}
|
||||
}
|
||||
|
||||
// costoDeUso aplica la tarifa del plan. Sin plan (tenants viejos) o tipo no
|
||||
// tarifado, el consumo se registra pero no cuesta.
|
||||
func costoDeUso(plan *UmindPlan, tipo string, cantidad float64) float64 {
|
||||
if plan == nil {
|
||||
return 0
|
||||
}
|
||||
switch tipo {
|
||||
case UsoTipoIA:
|
||||
return cantidad / 1000 * plan.PrecioPor1kTokens
|
||||
case UsoTipoOCR:
|
||||
return cantidad * plan.PrecioPorOCR
|
||||
case UsoTipoWhisper:
|
||||
return cantidad * plan.PrecioPorTranscripcion
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetUsoUmind lista el consumo de un tenant en un rango. tipo vacío = todos.
|
||||
func GetUsoUmind(tenantID uint, desde, hasta time.Time, tipo string) ([]UmindUso, error) {
|
||||
var items []UmindUso
|
||||
db := app.Http.Database.DB.Where("tenant_id = ? AND created_at >= ? AND created_at < ?", tenantID, desde, hasta)
|
||||
if tipo != "" {
|
||||
db = db.Where("tipo = ?", tipo)
|
||||
}
|
||||
err := db.Order("created_at DESC").Limit(1000).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// ResumenUso es el agregado por tipo que se muestra en el panel y se adjunta
|
||||
// al correo de cobro.
|
||||
type ResumenUso struct {
|
||||
Tipo string `json:"tipo"`
|
||||
Unidad string `json:"unidad"`
|
||||
Cantidad float64 `json:"cantidad"`
|
||||
Costo float64 `json:"costo"`
|
||||
Eventos int64 `json:"eventos"`
|
||||
}
|
||||
|
||||
func GetResumenUso(tenantID uint, desde, hasta time.Time) ([]ResumenUso, error) {
|
||||
var out []ResumenUso
|
||||
err := app.Http.Database.DB.Model(&UmindUso{}).
|
||||
Select("tipo, MAX(unidad) AS unidad, SUM(cantidad) AS cantidad, SUM(costo) AS costo, COUNT(*) AS eventos").
|
||||
Where("tenant_id = ? AND created_at >= ? AND created_at < ? AND deleted_at IS NULL", tenantID, desde, hasta).
|
||||
Group("tipo").Scan(&out).Error
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SumarUsoPendiente devuelve el consumo todavía no facturado de un tenant —
|
||||
// es lo que se le suma a la mensualidad al generar el link de cobro.
|
||||
func SumarUsoPendiente(tenantID uint) (float64, error) {
|
||||
var total float64
|
||||
err := app.Http.Database.DB.Model(&UmindUso{}).
|
||||
Where("tenant_id = ? AND facturado_at IS NULL AND deleted_at IS NULL", tenantID).
|
||||
Select("COALESCE(SUM(costo), 0)").Scan(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// MarcarUsoFacturado cierra el consumo pendiente de un tenant. Es idempotente
|
||||
// por construcción: el filtro facturado_at IS NULL hace que una segunda
|
||||
// llamada (webhook de pago duplicado) no encuentre nada que marcar.
|
||||
func MarcarUsoFacturado(tenantID uint) error {
|
||||
ahora := time.Now()
|
||||
return app.Http.Database.DB.Model(&UmindUso{}).
|
||||
Where("tenant_id = ? AND facturado_at IS NULL", tenantID).
|
||||
Update("facturado_at", ahora).Error
|
||||
}
|
||||
|
||||
// ─── Puente con la facturación por contrato ─────────────────────────────────
|
||||
// El cobro recurrente ya existente vive en Contrato (ver renovacion_service).
|
||||
// Un contrato es de un Cliente, y un Cliente puede tener varios tenants de
|
||||
// uMind, así que el consumo se agrega por cliente, no por tenant.
|
||||
|
||||
func tenantIDsDeCliente(clienteID uint) []uint {
|
||||
if clienteID == 0 {
|
||||
return nil
|
||||
}
|
||||
tenants, err := GetUmindTenantsByClientes([]uint{clienteID})
|
||||
if err != nil || len(tenants) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]uint, 0, len(tenants))
|
||||
for _, t := range tenants {
|
||||
ids = append(ids, t.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// ConsumoPendientePorCliente suma lo consumido y todavía no facturado de
|
||||
// todos los tenants de un cliente. Es lo que se le agrega a la mensualidad
|
||||
// al generar el link de cobro del ciclo.
|
||||
func ConsumoPendientePorCliente(clienteID uint) float64 {
|
||||
ids := tenantIDsDeCliente(clienteID)
|
||||
if len(ids) == 0 {
|
||||
return 0
|
||||
}
|
||||
var total float64
|
||||
if err := app.Http.Database.DB.Model(&UmindUso{}).
|
||||
Where("tenant_id IN ? AND facturado_at IS NULL AND deleted_at IS NULL", ids).
|
||||
Select("COALESCE(SUM(costo), 0)").Scan(&total).Error; err != nil {
|
||||
log.Printf("[UMIND_USO] no se pudo sumar el consumo pendiente del cliente %d: %v", clienteID, err)
|
||||
return 0
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// ResumenPendientePorCliente es el desglose que se adjunta al correo de cobro.
|
||||
// Un cobro variable sin detalle es una disputa asegurada.
|
||||
func ResumenPendientePorCliente(clienteID uint) []ResumenUso {
|
||||
ids := tenantIDsDeCliente(clienteID)
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []ResumenUso
|
||||
if err := app.Http.Database.DB.Model(&UmindUso{}).
|
||||
Select("tipo, MAX(unidad) AS unidad, SUM(cantidad) AS cantidad, SUM(costo) AS costo, COUNT(*) AS eventos").
|
||||
Where("tenant_id IN ? AND facturado_at IS NULL AND deleted_at IS NULL", ids).
|
||||
Group("tipo").Scan(&out).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MarcarUsoFacturadoPorCliente cierra el consumo pendiente tras confirmarse
|
||||
// el pago. Idempotente por el filtro facturado_at IS NULL: un webhook de pago
|
||||
// repetido no encuentra nada que marcar y no vuelve a cobrar.
|
||||
func MarcarUsoFacturadoPorCliente(clienteID uint) {
|
||||
ids := tenantIDsDeCliente(clienteID)
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
ahora := time.Now()
|
||||
if err := app.Http.Database.DB.Model(&UmindUso{}).
|
||||
Where("tenant_id IN ? AND facturado_at IS NULL", ids).
|
||||
Update("facturado_at", ahora).Error; err != nil {
|
||||
log.Printf("[UMIND_USO] no se pudo marcar como facturado el consumo del cliente %d: %v", clienteID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// agenteUsaCuentaPropia dice si el agente apunta a una config de IA cargada por
|
||||
// el cliente (con tenant), en vez de a una nuestra.
|
||||
func agenteUsaCuentaPropia(agente *UmindAgente) bool {
|
||||
if agente == nil || agente.AiConfigID == nil {
|
||||
return false
|
||||
}
|
||||
var cfg AiConfig
|
||||
if err := GetAiConfigByID(*agente.AiConfigID, &cfg); err != nil {
|
||||
return false
|
||||
}
|
||||
return cfg.TenantID != nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
// El costo se congela con el precio del plan al momento de registrar el
|
||||
// consumo. Si esta cuenta se rompe, se le cobra de más o de menos a un
|
||||
// cliente real, así que va con test.
|
||||
func TestCalculoCostoPorTipo(t *testing.T) {
|
||||
plan := &UmindPlan{
|
||||
PrecioPor1kTokens: 2.5,
|
||||
PrecioPorOCR: 10,
|
||||
PrecioPorTranscripcion: 40,
|
||||
}
|
||||
|
||||
casos := []struct {
|
||||
tipo string
|
||||
cantidad float64
|
||||
esperado float64
|
||||
}{
|
||||
{UsoTipoIA, 1000, 2.5}, // exactamente 1k tokens
|
||||
{UsoTipoIA, 500, 1.25}, // fracción de 1k, no se redondea hacia arriba
|
||||
{UsoTipoIA, 3200, 8.0}, // varios miles
|
||||
{UsoTipoOCR, 1, 10}, // una imagen
|
||||
{UsoTipoOCR, 3, 30}, // varias
|
||||
{UsoTipoWhisper, 1, 40}, // una transcripción
|
||||
{"desconocido", 100, 0}, // tipo no tarifado no cobra nada
|
||||
}
|
||||
|
||||
for _, cas := range casos {
|
||||
got := costoDeUso(plan, cas.tipo, cas.cantidad)
|
||||
if got != cas.esperado {
|
||||
t.Errorf("costoDeUso(%s, %.0f) = %.4f, esperaba %.4f", cas.tipo, cas.cantidad, got, cas.esperado)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sin plan asignado (tenants viejos) el consumo se registra pero no cuesta —
|
||||
// no debe explotar ni inventar un precio.
|
||||
func TestCalculoCostoSinPlan(t *testing.T) {
|
||||
if got := costoDeUso(nil, UsoTipoIA, 5000); got != 0 {
|
||||
t.Errorf("costoDeUso sin plan = %.4f, esperaba 0", got)
|
||||
}
|
||||
}
|
||||
+21
-7
@@ -45,9 +45,12 @@ func AllUsersGas(limit, offset int, search string) ([]Users, int64, error) {
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&Users{})
|
||||
|
||||
// Filtrar por término de búsqueda si se proporciona
|
||||
// ILIKE y no LIKE: en Postgres LIKE distingue mayúsculas, así que buscar
|
||||
// "lizandro" no encontraba a "Lizandro". Se agrega el email porque es lo
|
||||
// que la mayoría escribe cuando busca a una persona.
|
||||
if search != "" {
|
||||
db = db.Where("name LIKE ? OR nombre_usuario LIKE ?", "%"+search+"%", "%"+search+"%")
|
||||
patron := "%" + search + "%"
|
||||
db = db.Where("name ILIKE ? OR nombre_usuario ILIKE ? OR email ILIKE ?", patron, patron, patron)
|
||||
}
|
||||
// Filtrar por tipo_usuario = 'gas'
|
||||
db = db.Where("tipo_usuario = ?", "gas")
|
||||
@@ -72,13 +75,24 @@ func AllUsersSistema(limit, offset int, search string) ([]Users, int64, error) {
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&Users{})
|
||||
|
||||
// Filtrar por término de búsqueda si se proporciona
|
||||
// ILIKE y no LIKE: en Postgres LIKE distingue mayúsculas, así que buscar
|
||||
// "lizandro" no encontraba a "Lizandro". Se agrega el email porque es lo
|
||||
// que la mayoría escribe cuando busca a una persona.
|
||||
if search != "" {
|
||||
db = db.Where("name LIKE ? OR nombre_usuario LIKE ?", "%"+search+"%", "%"+search+"%")
|
||||
patron := "%" + search + "%"
|
||||
db = db.Where("name ILIKE ? OR nombre_usuario ILIKE ? OR email ILIKE ?", patron, patron, patron)
|
||||
}
|
||||
|
||||
// Filtrar por tipo_usuario = 'sistema' o vacío (excluye usuarios 'gas' y otros tipos)
|
||||
db = db.Where("tipo_usuario = ? OR tipo_usuario = '' OR tipo_usuario IS NULL", "sistema")
|
||||
// Se excluye 'gas' en vez de exigir 'sistema'.
|
||||
//
|
||||
// Antes era una lista blanca ('sistema', '' o NULL) y la pantalla de 'gas'
|
||||
// es la complementaria, así que un usuario con cualquier otro valor —o con
|
||||
// uno viejo de antes de que existiera el campo— no aparecía en NINGUNA de
|
||||
// las dos listas: quedaba invisible en todo el panel aunque pudiera entrar
|
||||
// y fuera administrador.
|
||||
//
|
||||
// Invertirlo garantiza que todo usuario esté en exactamente una de las dos.
|
||||
db = db.Where("COALESCE(tipo_usuario, '') <> ?", "gas")
|
||||
|
||||
// Obtener el total de usuarios
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
@@ -175,7 +189,7 @@ func FindUserByID(id interface{}) (*Users, error) {
|
||||
db := app.Http.Database.DB.Model(&Users{})
|
||||
|
||||
// Buscar el usuario por ID
|
||||
if err := db.Where("id = ?", id).Preload("Role.Submodules.Module").First(&user).Error; err != nil {
|
||||
if err := db.Where("id = ?", id).Preload("Role.Submodules.Module").Preload("Role.ConxDBs").First(&user).Error; err != nil {
|
||||
// Manejar el error si el registro no se encuentra
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("usuario no encontrado con ID: %d", id)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// WhisperAsrConfig almacena la conexión al servicio propio de transcripción
|
||||
// de audio (whisper-asr-webservice self-hosted, autenticado con Basic Auth)
|
||||
// — distinto del Whisper de OpenAI que ya se configura vía AiConfig con
|
||||
// modulo "whisper" para el bot de Telegram. Solo un registro activo a la vez.
|
||||
type WhisperAsrConfig struct {
|
||||
gorm.Model
|
||||
BaseURL string `json:"base_url" gorm:"column:base_url;type:text;not null"` // ej: https://whisper.u-s.app/asr
|
||||
Username string `json:"username" gorm:"column:username;size:255;not null"`
|
||||
Password string `json:"password" gorm:"column:password;type:text;not null"`
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (WhisperAsrConfig) TableName() string { return "whisper_asr_config" }
|
||||
|
||||
func GetWhisperAsrConfig() (*WhisperAsrConfig, error) {
|
||||
var item WhisperAsrConfig
|
||||
if err := app.Http.Database.DB.Where("activo = ?", true).Order("id DESC").First(&item).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func SaveWhisperAsrConfig(s WhisperAsrConfig) error {
|
||||
app.Http.Database.DB.Model(&WhisperAsrConfig{}).Where("activo = ?", true).Update("activo", false)
|
||||
s.Activo = true
|
||||
if s.ID > 0 {
|
||||
return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
|
||||
"base_url": s.BaseURL,
|
||||
"username": s.Username,
|
||||
"password": s.Password,
|
||||
"notas": s.Notas,
|
||||
"activo": true,
|
||||
}).Error
|
||||
}
|
||||
return app.Http.Database.DB.Create(&s).Error
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAgentMessageRawRoundTrip confirma que un campo que el proveedor manda
|
||||
// y que nuestra struct no conoce (ej. thought_signature de Gemini) sobrevive
|
||||
// a un ciclo unmarshal->marshal en vez de perderse — es justo lo que hacía
|
||||
// que Gemini rechazara la segunda ronda de una tool call.
|
||||
func TestAgentMessageRawRoundTrip(t *testing.T) {
|
||||
original := `{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"buscar_conocimiento","arguments":"{}"},"thought_signature":"opaco-123"}]}`
|
||||
|
||||
var msg agentMessage
|
||||
if err := json.Unmarshal([]byte(original), &msg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(msg.ToolCalls) != 1 || msg.ToolCalls[0].Function.Name != "buscar_conocimiento" {
|
||||
t.Fatalf("no se poblaron los campos tipados normalmente: %+v", msg)
|
||||
}
|
||||
|
||||
out, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), `"thought_signature":"opaco-123"`) {
|
||||
t.Errorf("se perdió el campo desconocido al reserializar: %s", out)
|
||||
}
|
||||
|
||||
// Un mensaje armado por nosotros (no parseado) no debe llevar Raw ni
|
||||
// romperse por eso — tiene que serializar normal.
|
||||
propio := agentMessage{Role: "user", Content: "hola"}
|
||||
outPropio, err := json.Marshal(propio)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal de mensaje propio: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(outPropio), `"role":"user"`) || !strings.Contains(string(outPropio), `"content":"hola"`) {
|
||||
t.Errorf("mensaje propio no serializó bien: %s", outPropio)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// Resolución de entidades por nombre para el agente de Telegram.
|
||||
//
|
||||
// La mayoría de las herramientas pedían un ID numérico, y el modelo tenía que
|
||||
// conseguirlo antes: llamar a listar_*, leer la respuesta, encontrar la fila
|
||||
// correcta y extraer el número. Cuatro pasos encadenados por cada acción, y
|
||||
// basta que falle uno para que no se guarde nada — es lo que pasó con una
|
||||
// factura cuyo cliente no salía en la primera página del listado.
|
||||
//
|
||||
// Aceptando el nombre y resolviéndolo acá, esa cadena desaparece: el modelo
|
||||
// pasa lo que el usuario dijo y el servidor hace la búsqueda, que además es
|
||||
// exacta y no depende de que el listado estuviera paginado.
|
||||
|
||||
// ResolverClienteID devuelve el ID a partir de un id explícito o de un nombre.
|
||||
//
|
||||
// Si el nombre coincide con varios clientes devuelve un error que los enumera:
|
||||
// es mejor que el modelo pregunte cuál a que elija uno al azar y la factura
|
||||
// termine cargada a otra empresa.
|
||||
func ResolverClienteID(id uint, nombre string) (uint, error) {
|
||||
if id > 0 {
|
||||
return id, nil
|
||||
}
|
||||
nombre = strings.TrimSpace(nombre)
|
||||
if nombre == "" {
|
||||
return 0, fmt.Errorf("indicá el cliente: su nombre o su id")
|
||||
}
|
||||
|
||||
items, _, err := models.GetAllClientes(50, 0, nombre)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
switch len(items) {
|
||||
case 0:
|
||||
return 0, fmt.Errorf("no encontré ningún cliente que coincida con %q; revisá el nombre o creálo con crear_cliente", nombre)
|
||||
case 1:
|
||||
return items[0].ID, nil
|
||||
}
|
||||
|
||||
// Una coincidencia exacta gana sobre las parciales: "Metropolitana" no
|
||||
// debería quedar ambiguo solo porque existe "Metropolitana Norte".
|
||||
buscado := strings.ToLower(nombre)
|
||||
for _, c := range items {
|
||||
if strings.ToLower(strings.TrimSpace(c.Nombre)) == buscado ||
|
||||
strings.ToLower(strings.TrimSpace(c.Empresa)) == buscado {
|
||||
return c.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
var opciones []string
|
||||
for i, c := range items {
|
||||
if i == 8 {
|
||||
opciones = append(opciones, fmt.Sprintf("y %d más", len(items)-8))
|
||||
break
|
||||
}
|
||||
etiqueta := c.Nombre
|
||||
if c.Empresa != "" && !strings.EqualFold(c.Empresa, c.Nombre) {
|
||||
etiqueta = fmt.Sprintf("%s (%s)", c.Nombre, c.Empresa)
|
||||
}
|
||||
opciones = append(opciones, fmt.Sprintf("%d = %s", c.ID, etiqueta))
|
||||
}
|
||||
return 0, fmt.Errorf("hay varios clientes que coinciden con %q, preguntale al usuario cuál: %s",
|
||||
nombre, strings.Join(opciones, "; "))
|
||||
}
|
||||
|
||||
// ─── Salud de las herramientas ───────────────────────────────────────────────
|
||||
|
||||
// Contadores en memoria de uso y fallo por herramienta.
|
||||
//
|
||||
// Hasta ahora cada problema se diagnosticaba de a un caso: el usuario avisaba
|
||||
// que algo no se guardó y había que reconstruir qué pasó. Con esto se ve de
|
||||
// una si el fallo es de una herramienta puntual o del modelo eligiendo mal.
|
||||
//
|
||||
// ponytail: en memoria, se reinicia con el proceso. Alcanza para responder
|
||||
// "¿qué está fallando esta semana?"; si hiciera falta histórico, va a tabla.
|
||||
var (
|
||||
agentStatsMu sync.Mutex
|
||||
agentUsos = map[string]int{}
|
||||
agentFallos = map[string]int{}
|
||||
)
|
||||
|
||||
func RegistrarUsoHerramienta(nombre string, fallo bool) {
|
||||
agentStatsMu.Lock()
|
||||
defer agentStatsMu.Unlock()
|
||||
agentUsos[nombre]++
|
||||
if fallo {
|
||||
agentFallos[nombre]++
|
||||
}
|
||||
}
|
||||
|
||||
// EstadoHerramientas devuelve el resumen ordenado por cantidad de fallos.
|
||||
func EstadoHerramientas() []map[string]interface{} {
|
||||
agentStatsMu.Lock()
|
||||
defer agentStatsMu.Unlock()
|
||||
|
||||
out := make([]map[string]interface{}, 0, len(agentUsos))
|
||||
for nombre, usos := range agentUsos {
|
||||
out = append(out, map[string]interface{}{
|
||||
"herramienta": nombre,
|
||||
"usos": usos,
|
||||
"fallos": agentFallos[nombre],
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
fi, fj := out[i]["fallos"].(int), out[j]["fallos"].(int)
|
||||
if fi != fj {
|
||||
return fi > fj
|
||||
}
|
||||
return out[i]["usos"].(int) > out[j]["usos"].(int)
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ExtraerTextoDeArchivo saca el texto de un archivo cualquiera para dárselo al
|
||||
// agente. Es el equivalente de Whisper para audio y OCR para imágenes, pero
|
||||
// para documentos.
|
||||
//
|
||||
// agenteID identifica a quién cobrarle si hace falta pasar por OCR; 0 = no medir.
|
||||
func ExtraerTextoDeArchivo(agenteID uint, nombreArchivo string, datos []byte) (string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(nombreArchivo))
|
||||
switch ext {
|
||||
case ".txt", ".md", ".csv", ".json", ".xml", ".log", ".html", ".htm":
|
||||
return string(datos), nil
|
||||
case ".docx":
|
||||
return textoDeDocx(datos)
|
||||
case ".pdf":
|
||||
return textoDePDF(agenteID, datos)
|
||||
case ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff":
|
||||
return ExtraerTextoOCR(agenteID, datos, mimeDeImagen(ext))
|
||||
default:
|
||||
return "", fmt.Errorf("no sé leer archivos %s; probá con PDF, Word (.docx), texto o una imagen", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func mimeDeImagen(ext string) string {
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".tif", ".tiff":
|
||||
return "image/tiff"
|
||||
default:
|
||||
return "image/" + strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
}
|
||||
|
||||
var etiquetaXML = regexp.MustCompile(`<[^>]+>`)
|
||||
|
||||
// textoDeDocx lee word/document.xml del .docx y lo aplana a texto. No pretende
|
||||
// conservar el formato: el agente solo necesita el contenido y el orden.
|
||||
func textoDeDocx(datos []byte) (string, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(datos), int64(len(datos)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("el .docx no se pudo abrir: %w", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
xmlBytes, err := io.ReadAll(io.LimitReader(rc, 8<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := string(xmlBytes)
|
||||
// Un párrafo, un salto de línea explícito y un fin de fila valen como
|
||||
// salto; las celdas se separan con tab. El resto de etiquetas se tira.
|
||||
s = strings.NewReplacer("</w:p>", "\n", "<w:br/>", "\n", "</w:tr>", "\n", "</w:tc>", "\t").Replace(s)
|
||||
s = etiquetaXML.ReplaceAllString(s, "")
|
||||
s = strings.NewReplacer("&", "&", "<", "<", ">", ">", """, `"`, "'", "'").Replace(s)
|
||||
return strings.TrimSpace(s), nil
|
||||
}
|
||||
return "", fmt.Errorf("el archivo no parece un .docx (no tiene word/document.xml)")
|
||||
}
|
||||
|
||||
// textoDePDF saca el texto de un PDF digital (facturas, cotizaciones, cualquier
|
||||
// cosa exportada por un programa) leyendo los operadores de texto de sus
|
||||
// streams. Si el PDF es un escaneo no hay texto que leer, y ahí cae al OCR.
|
||||
//
|
||||
// ponytail: parser mínimo — entiende streams FlateDecode y los operadores Tj/TJ,
|
||||
// que es lo que usan los PDFs generados por software. No maneja fuentes con
|
||||
// codificaciones raras ni CID; para esos casos el fallback a OCR es la salida.
|
||||
func textoDePDF(agenteID uint, datos []byte) (string, error) {
|
||||
texto := strings.TrimSpace(textoDeStreamsPDF(datos))
|
||||
// Un PDF escaneado devuelve nada o cuatro letras sueltas de un encabezado.
|
||||
if len([]rune(texto)) >= 40 {
|
||||
return texto, nil
|
||||
}
|
||||
ocrTexto, err := ExtraerTextoOCR(agenteID, datos, "application/pdf")
|
||||
if err != nil {
|
||||
if texto != "" {
|
||||
return texto, nil
|
||||
}
|
||||
return "", fmt.Errorf("el PDF no tiene texto legible y el OCR no pudo procesarlo: %w", err)
|
||||
}
|
||||
return ocrTexto, nil
|
||||
}
|
||||
|
||||
var streamRe = regexp.MustCompile(`(?s)stream\r?\n(.*?)endstream`)
|
||||
|
||||
func textoDeStreamsPDF(datos []byte) string {
|
||||
var out strings.Builder
|
||||
for _, m := range streamRe.FindAllSubmatch(datos, -1) {
|
||||
crudo := m[1]
|
||||
contenido := crudo
|
||||
if zr, err := zlib.NewReader(bytes.NewReader(crudo)); err == nil {
|
||||
if inflado, err := io.ReadAll(io.LimitReader(zr, 16<<20)); err == nil {
|
||||
contenido = inflado
|
||||
}
|
||||
zr.Close()
|
||||
}
|
||||
if !bytes.Contains(contenido, []byte("Tj")) && !bytes.Contains(contenido, []byte("TJ")) {
|
||||
continue
|
||||
}
|
||||
out.WriteString(textoDeContenidoPDF(contenido))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// textoDeContenidoPDF junta las cadenas entre paréntesis de un content stream,
|
||||
// que es donde vive el texto visible, y respeta los saltos de línea (T*, TD, Td).
|
||||
func textoDeContenidoPDF(contenido []byte) string {
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(contenido); i++ {
|
||||
switch contenido[i] {
|
||||
case '(':
|
||||
var s strings.Builder
|
||||
for i++; i < len(contenido); i++ {
|
||||
c := contenido[i]
|
||||
if c == '\\' && i+1 < len(contenido) {
|
||||
i++
|
||||
switch contenido[i] {
|
||||
case 'n':
|
||||
s.WriteByte('\n')
|
||||
case 't':
|
||||
s.WriteByte('\t')
|
||||
case 'r':
|
||||
default:
|
||||
s.WriteByte(contenido[i])
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c == ')' {
|
||||
break
|
||||
}
|
||||
s.WriteByte(c)
|
||||
}
|
||||
out.WriteString(s.String())
|
||||
case 'T':
|
||||
// T* / Td / TD mueven el cursor a otra línea.
|
||||
if i+1 < len(contenido) {
|
||||
switch contenido[i+1] {
|
||||
case '*', 'd', 'D':
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return limpiarNoImprimibles(out.String())
|
||||
}
|
||||
|
||||
func limpiarNoImprimibles(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\t' || unicode.IsPrint(r) {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, s)
|
||||
}
|
||||
|
||||
// TextoDeArchivoParaAgente arma el mensaje que ve el agente cuando alguien le
|
||||
// manda un documento: el contenido solo, sin contexto, hace que el modelo
|
||||
// conteste como si el cliente hubiera escrito una factura.
|
||||
func TextoDeArchivoParaAgente(nombreArchivo, caption, contenido string) string {
|
||||
if len(contenido) > 30000 {
|
||||
contenido = contenido[:30000] + "\n…(archivo recortado)"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("El cliente adjuntó un archivo")
|
||||
if nombreArchivo != "" {
|
||||
b.WriteString(" llamado \"" + nombreArchivo + "\"")
|
||||
}
|
||||
b.WriteString(".")
|
||||
if strings.TrimSpace(caption) != "" {
|
||||
b.WriteString(" Escribió junto al archivo: " + strings.TrimSpace(caption))
|
||||
}
|
||||
b.WriteString("\n\nContenido del archivo:\n---\n" + strings.TrimSpace(contenido) + "\n---")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pdfDePrueba arma un PDF mínimo con el content stream comprimido, igual que
|
||||
// los que genera cualquier programa que exporta a PDF.
|
||||
func pdfDePrueba(t *testing.T, contenido string) []byte {
|
||||
t.Helper()
|
||||
var comp bytes.Buffer
|
||||
zw := zlib.NewWriter(&comp)
|
||||
if _, err := zw.Write([]byte(contenido)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zw.Close()
|
||||
|
||||
var pdf bytes.Buffer
|
||||
pdf.WriteString("%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n")
|
||||
pdf.WriteString("2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n")
|
||||
pdf.WriteString("3 0 obj<</Type/Page/Parent 2 0 R/Contents 4 0 R>>endobj\n")
|
||||
pdf.WriteString("4 0 obj<</Length " + strconv.Itoa(comp.Len()) + "/Filter/FlateDecode>>stream\n")
|
||||
pdf.Write(comp.Bytes())
|
||||
pdf.WriteString("\nendstream endobj\ntrailer<</Root 1 0 R>>\n%%EOF")
|
||||
return pdf.Bytes()
|
||||
}
|
||||
|
||||
func TestExtraerTextoDeArchivoPDFDigital(t *testing.T) {
|
||||
contenido := `BT /F1 12 Tf 72 720 Td (FACTURA DE VENTA No. 1042) Tj T* ` +
|
||||
`(Cliente: Acme SAS NIT 900.123.456-7) Tj T* (Total: \$1.500.000 COP) Tj ET`
|
||||
|
||||
got, err := ExtraerTextoDeArchivo(0, "factura.pdf", pdfDePrueba(t, contenido))
|
||||
if err != nil {
|
||||
t.Fatalf("ExtraerTextoDeArchivo: %v", err)
|
||||
}
|
||||
for _, quiero := range []string{"FACTURA DE VENTA No. 1042", "Acme SAS", "NIT 900.123.456-7", "$1.500.000 COP"} {
|
||||
if !strings.Contains(got, quiero) {
|
||||
t.Errorf("falta %q en el texto extraído:\n%s", quiero, got)
|
||||
}
|
||||
}
|
||||
// T* separa renglones: sin eso la factura llega al agente como un chorizo.
|
||||
if lineas := strings.Count(strings.TrimSpace(got), "\n"); lineas < 2 {
|
||||
t.Errorf("esperaba al menos 3 renglones, hay %d:\n%s", lineas+1, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtraerTextoDeArchivoTexto(t *testing.T) {
|
||||
got, err := ExtraerTextoDeArchivo(0, "notas.txt", []byte("hola\nmundo"))
|
||||
if err != nil || got != "hola\nmundo" {
|
||||
t.Errorf("got %q, err %v", got, err)
|
||||
}
|
||||
if _, err := ExtraerTextoDeArchivo(0, "cosa.exe", []byte("x")); err == nil {
|
||||
t.Error("una extensión desconocida debería devolver error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextoDeArchivoParaAgente(t *testing.T) {
|
||||
got := TextoDeArchivoParaAgente("factura.pdf", "me cobraron de más", "Total: 1000")
|
||||
for _, quiero := range []string{"factura.pdf", "me cobraron de más", "Total: 1000"} {
|
||||
if !strings.Contains(got, quiero) {
|
||||
t.Errorf("falta %q en:\n%s", quiero, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// GenerarArquitectura produce el PDF de una propuesta técnica a partir de un
|
||||
// requerimiento en texto libre. Quien llama (Claude) ya consultó
|
||||
// listar_arquitecturas_referencia para reutilizar patrones ya resueltos en vez de
|
||||
// improvisar, y trae la propuesta ya armada; el backend solo la renderiza a PDF y,
|
||||
// si aplica, la deja guardada como nueva referencia reutilizable.
|
||||
func GenerarArquitectura(requerimiento, propuesta, nombre string, clienteID *uint, guardarComoReferencia bool, generadoPor string) (*models.DocumentoGenerado, error) {
|
||||
if requerimiento == "" {
|
||||
return nil, fmt.Errorf("requerimiento requerido")
|
||||
}
|
||||
if propuesta == "" {
|
||||
return nil, fmt.Errorf("propuesta requerida")
|
||||
}
|
||||
if nombre == "" {
|
||||
nombre = "Propuesta técnica"
|
||||
}
|
||||
|
||||
var cliente *models.Cliente
|
||||
if clienteID != nil {
|
||||
c, err := models.GetClienteByID(*clienteID)
|
||||
if err == nil {
|
||||
cliente = c
|
||||
}
|
||||
}
|
||||
|
||||
datos := DatosBaseDocumento(map[string]interface{}{
|
||||
"Nombre": nombre,
|
||||
"Requerimiento": requerimiento,
|
||||
"Propuesta": propuesta,
|
||||
"Cliente": cliente,
|
||||
})
|
||||
|
||||
doc, _, err := GenerarDocumento("arquitectura", datos, clienteID, nil, generadoPor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if guardarComoReferencia {
|
||||
ref := &models.Arquitectura{
|
||||
Nombre: nombre,
|
||||
Descripcion: requerimiento,
|
||||
ContenidoHTML: propuesta,
|
||||
EsReferencia: true,
|
||||
ClienteID: clienteID,
|
||||
}
|
||||
_ = models.CreateArquitectura(ref) // no bloquea la generación del documento si falla
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
@@ -134,18 +134,18 @@ type CFZoneAccount struct {
|
||||
|
||||
// CFZone representa una zona (dominio) en Cloudflare.
|
||||
type CFZone struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Paused bool `json:"paused"`
|
||||
Type string `json:"type"`
|
||||
NameServers []string `json:"name_servers"`
|
||||
OriginalNS []string `json:"original_name_servers"`
|
||||
CreatedOn string `json:"created_on"`
|
||||
ModifiedOn string `json:"modified_on"`
|
||||
ActivatedOn string `json:"activated_on"`
|
||||
Account CFZoneAccount `json:"account"`
|
||||
Plan CFPlan `json:"plan"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Paused bool `json:"paused"`
|
||||
Type string `json:"type"`
|
||||
NameServers []string `json:"name_servers"`
|
||||
OriginalNS []string `json:"original_name_servers"`
|
||||
CreatedOn string `json:"created_on"`
|
||||
ModifiedOn string `json:"modified_on"`
|
||||
ActivatedOn string `json:"activated_on"`
|
||||
Account CFZoneAccount `json:"account"`
|
||||
Plan CFPlan `json:"plan"`
|
||||
}
|
||||
|
||||
// CFPlan representa el plan de una zona.
|
||||
@@ -162,30 +162,30 @@ type CFDNSRecordSettings struct {
|
||||
|
||||
// CFDNSRecord representa un registro DNS.
|
||||
type CFDNSRecord struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Settings CFDNSRecordSettings `json:"settings,omitempty"`
|
||||
PrivateRouting bool `json:"private_routing,omitempty"`
|
||||
Proxied bool `json:"proxied"`
|
||||
Proxiable bool `json:"proxiable"`
|
||||
TTL int `json:"ttl"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
CreatedOn string `json:"created_on"`
|
||||
ModifiedOn string `json:"modified_on"`
|
||||
CommentModifiedOn string `json:"comment_modified_on,omitempty"`
|
||||
TagsModifiedOn string `json:"tags_modified_on,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Settings CFDNSRecordSettings `json:"settings,omitempty"`
|
||||
PrivateRouting bool `json:"private_routing,omitempty"`
|
||||
Proxied bool `json:"proxied"`
|
||||
Proxiable bool `json:"proxiable"`
|
||||
TTL int `json:"ttl"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
CreatedOn string `json:"created_on"`
|
||||
ModifiedOn string `json:"modified_on"`
|
||||
CommentModifiedOn string `json:"comment_modified_on,omitempty"`
|
||||
TagsModifiedOn string `json:"tags_modified_on,omitempty"`
|
||||
}
|
||||
|
||||
// CFSSLStatus representa un certificate pack de una zona.
|
||||
type CFSSLStatus struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // universal, advanced, custom, sni_custom
|
||||
Type string `json:"type"` // universal, advanced, custom, sni_custom
|
||||
Hosts []string `json:"hosts"`
|
||||
Status string `json:"status"` // active, pending_validation, deleted
|
||||
Status string `json:"status"` // active, pending_validation, deleted
|
||||
ValidationMethod string `json:"validation_method,omitempty"`
|
||||
ValidityDays int `json:"validity_days,omitempty"`
|
||||
CertificateAuthority string `json:"certificate_authority,omitempty"`
|
||||
@@ -243,10 +243,10 @@ type CFTokenVerify struct {
|
||||
|
||||
// CFTokenPolicy representa una política de permisos del token.
|
||||
type CFTokenPolicy struct {
|
||||
ID string `json:"id"`
|
||||
Effect string `json:"effect"`
|
||||
Resources map[string]string `json:"resources"`
|
||||
PermGroups []CFTokenPermGroup `json:"permission_groups"`
|
||||
ID string `json:"id"`
|
||||
Effect string `json:"effect"`
|
||||
Resources map[string]string `json:"resources"`
|
||||
PermGroups []CFTokenPermGroup `json:"permission_groups"`
|
||||
}
|
||||
|
||||
// CFTokenPermGroup un grupo de permisos.
|
||||
@@ -478,14 +478,14 @@ func (c *CloudflareClient) doRequest(method, path string, payload interface{}, d
|
||||
|
||||
// CFDNSRecordInput es el payload para crear o actualizar un registro DNS.
|
||||
type CFDNSRecordInput struct {
|
||||
Type string `json:"type"` // A, AAAA, CNAME, TXT, MX, NS, SRV, CAA…
|
||||
Name string `json:"name"` // Nombre del registro (ej. "www" o "@")
|
||||
Content string `json:"content"` // Valor del registro
|
||||
TTL int `json:"ttl"` // 1 = automático, o segundos (min 60)
|
||||
Proxied bool `json:"proxied"` // true = nube naranja
|
||||
Priority int `json:"priority,omitempty"` // Solo para MX / SRV
|
||||
Comment string `json:"comment,omitempty"` // Comentario descriptivo
|
||||
Tags []string `json:"tags,omitempty"` // Etiquetas (ej. ["owner:team"])
|
||||
Type string `json:"type"` // A, AAAA, CNAME, TXT, MX, NS, SRV, CAA…
|
||||
Name string `json:"name"` // Nombre del registro (ej. "www" o "@")
|
||||
Content string `json:"content"` // Valor del registro
|
||||
TTL int `json:"ttl"` // 1 = automático, o segundos (min 60)
|
||||
Proxied bool `json:"proxied"` // true = nube naranja
|
||||
Priority int `json:"priority,omitempty"` // Solo para MX / SRV
|
||||
Comment string `json:"comment,omitempty"` // Comentario descriptivo
|
||||
Tags []string `json:"tags,omitempty"` // Etiquetas (ej. ["owner:team"])
|
||||
Settings *CFDNSRecordSettings `json:"settings,omitempty"` // Configuraciones adicionales
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// GenerarDocumentoContrato produce el PDF de un contrato ya existente a partir de
|
||||
// la plantilla activa tipo 'contrato' (cláusulas estándar) + los datos del contrato
|
||||
// (cliente, servicios, vigencia, valor). Es la implementación única que comparten
|
||||
// el endpoint POST /api/v2/contratos/:id/generar-documento y la tool crear_contrato.
|
||||
func GenerarDocumentoContrato(contratoID uint, generadoPor string) (*models.DocumentoGenerado, error) {
|
||||
contrato, err := models.GetContratoByID(contratoID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contrato no encontrado: %w", err)
|
||||
}
|
||||
|
||||
datos := DatosBaseDocumento(map[string]interface{}{
|
||||
"Contrato": contrato,
|
||||
"Cliente": contrato.Cliente,
|
||||
"Servicios": contrato.Servicios,
|
||||
"FechaInicio": contrato.FechaInicio.Format("02/01/2006"),
|
||||
"FechaVencimiento": contrato.FechaVencimiento.Format("02/01/2006"),
|
||||
"PrecioAcordado": contrato.PrecioAcordado,
|
||||
"Moneda": contrato.Moneda,
|
||||
"Notas": contrato.Notas,
|
||||
})
|
||||
|
||||
doc, _, err := GenerarDocumento("contrato", datos, &contrato.ClienteID, nil, generadoPor)
|
||||
return doc, err
|
||||
}
|
||||
|
||||
// CrearContratoConDocumento crea el registro de contrato y de una vez genera su PDF
|
||||
// con cláusulas estándar, para el flujo "cliente, tipo de servicio, duración" que
|
||||
// describe la automatización con IA (Telegram / chat propio / Claude directo).
|
||||
func CrearContratoConDocumento(clienteID uint, servicioIDs []uint, duracionMeses int, precioAcordado float64, moneda, notas, generadoPor string) (*models.Contrato, *models.DocumentoGenerado, error) {
|
||||
if clienteID == 0 {
|
||||
return nil, nil, fmt.Errorf("cliente_id requerido")
|
||||
}
|
||||
if len(servicioIDs) == 0 {
|
||||
return nil, nil, fmt.Errorf("servicio_ids requerido (al menos un servicio)")
|
||||
}
|
||||
if duracionMeses <= 0 {
|
||||
duracionMeses = 12
|
||||
}
|
||||
if moneda == "" {
|
||||
moneda = "COP"
|
||||
}
|
||||
|
||||
inicio := time.Now()
|
||||
vencimiento := inicio.AddDate(0, duracionMeses, 0)
|
||||
|
||||
c := models.Contrato{
|
||||
ClienteID: clienteID,
|
||||
FechaInicio: inicio,
|
||||
FechaVencimiento: vencimiento,
|
||||
PrecioAcordado: precioAcordado,
|
||||
Moneda: moneda,
|
||||
Estado: "activo",
|
||||
Notas: notas,
|
||||
}
|
||||
if err := models.CreateContrato(c, servicioIDs, nil, nil, nil); err != nil {
|
||||
return nil, nil, fmt.Errorf("no se pudo crear el contrato: %w", err)
|
||||
}
|
||||
|
||||
// Recargar con Cliente/Servicios precargados (CreateContrato no los devuelve).
|
||||
creado, err := models.GetUltimoContratoByCliente(clienteID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("contrato creado pero no se pudo recargar: %w", err)
|
||||
}
|
||||
|
||||
doc, err := GenerarDocumentoContrato(creado.ID, generadoPor)
|
||||
if err != nil {
|
||||
return creado, nil, err
|
||||
}
|
||||
return creado, doc, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user