feat: modulo WhatsApp (WA CRM) con round-robin de conversaciones
- Servicio wacrm en api-gateway (pg pool lazy): round-robin de conversaciones sin agente entre vendedores vinculados y miembro-agentes de la cuenta wacrm, cron cada 30 s; bandeja del vendedor; estado/config/reasignar para admin. - Endpoints /api/wacrm/* con guarda de rol admin/manager. - Usuario.wacrmUserId en schema Prisma (vinculo CRM<->wacrm). - App apps/wa-crm (/wa-crm/): bandeja + administracion del round-robin. - Landing: tarjeta WhatsApp; clave 'whatsapp' en MATRIZ de roles. - Plan de nuevas reglas de negocio del cotizador (docs/PLAN-REGLAS-COTIZADOR.md). - WACRM_PG_URL apunta a Postgres de Supabase por 127.0.0.1:5433. - Tests de integracion wacrm (3) y suite completa 764/764 en verde.
This commit is contained in:
160
api-gateway/package-lock.json
generated
160
api-gateway/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.6.0",
|
||||
"pg": "^8.23.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
@@ -27,6 +28,7 @@
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.15.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"prisma": "^6.14.0",
|
||||
"tsx": "^4.20.3",
|
||||
@@ -1086,6 +1088,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
@@ -2658,6 +2672,95 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.23.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.16.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
|
||||
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2719,6 +2822,45 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
||||
@@ -3098,6 +3240,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
@@ -3600,6 +3751,15 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.6.0",
|
||||
"pg": "^8.23.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
@@ -40,6 +41,7 @@
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.15.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"prisma": "^6.14.0",
|
||||
"tsx": "^4.20.3",
|
||||
|
||||
@@ -318,6 +318,9 @@ model Usuario {
|
||||
// User ID de Monday (people). null = el usuario aún no tiene cuenta Monday;
|
||||
// los filtros "míos" de /api/mi-dia responden vacíos con sinMapeoMonday: true
|
||||
mondayUserId Int?
|
||||
// ID del usuario en WA CRM (auth.users del Supabase de wacrm). null = sin
|
||||
// vínculo; el módulo WhatsApp del CRM lo usa para la bandeja y el round-robin
|
||||
wacrmUserId String?
|
||||
activo Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -12,6 +12,7 @@ export const MODULOS = [
|
||||
'cobranza',
|
||||
'contactos',
|
||||
'chat',
|
||||
'whatsapp',
|
||||
'clientes-maestro',
|
||||
'captura',
|
||||
'soporte',
|
||||
@@ -39,6 +40,7 @@ const MODULOS_VENDEDOR: readonly Modulo[] = [
|
||||
'mi-tablero',
|
||||
'contactos',
|
||||
'chat',
|
||||
'whatsapp',
|
||||
'clientes-maestro',
|
||||
'captura',
|
||||
'soporte',
|
||||
@@ -49,7 +51,7 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
|
||||
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
|
||||
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
||||
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'whatsapp', 'clientes-maestro', 'captura', 'soporte'],
|
||||
// manager: mismos módulos que admin, sin poder crear usuarios ni editar permisos
|
||||
// ni dar de alta desarrollos (módulo exclusivo del admin)
|
||||
manager: MODULOS.filter((m) => m !== 'desarrollos'),
|
||||
|
||||
67
api-gateway/src/controllers/wacrm.controller.ts
Normal file
67
api-gateway/src/controllers/wacrm.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Endpoints del módulo WhatsApp (WA CRM): bandeja del vendedor, estado del
|
||||
* round-robin para administración, configuración y reasignación manual.
|
||||
*/
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
import {
|
||||
configurarWacrm,
|
||||
estadoWacrm,
|
||||
ejecutarRoundRobinWacrm,
|
||||
miBandeja,
|
||||
reasignarConversacion,
|
||||
} from '../services/wacrm.service.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
|
||||
/** Admin/manager: estado, configuración y reasignación. */
|
||||
export function requireRolWacrmAdmin(req: Request, _res: Response, next: NextFunction): void {
|
||||
const rol = req.usuarioToken?.rol
|
||||
if (rol !== 'admin' && rol !== 'manager') {
|
||||
throw new AppError(403, 'Requiere rol de administrador')
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
/** Bandeja de WhatsApp del vendedor logueado (vínculo vía wacrmUserId). */
|
||||
export async function miBandejaEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const usuarioId = req.usuarioToken?.sub
|
||||
if (!usuarioId) throw new AppError(401, 'Sesión requerida')
|
||||
const resultado = await miBandeja(usuarioId)
|
||||
res.json(resultado)
|
||||
}
|
||||
|
||||
export async function estadoWacrmEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await estadoWacrm())
|
||||
}
|
||||
|
||||
const configSchema = z
|
||||
.object({
|
||||
cuentaId: z.string().max(64).optional(),
|
||||
activo: z.boolean().optional(),
|
||||
})
|
||||
.refine((o) => Object.keys(o).length > 0, { message: 'Nada que actualizar' })
|
||||
|
||||
export async function configurarWacrmEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = configSchema.parse(req.body)
|
||||
const resultado = await configurarWacrm(body)
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CONFIG_WACRM', 'configuracion', 'wacrm', JSON.stringify(body))
|
||||
res.json(resultado)
|
||||
}
|
||||
|
||||
const reasignarSchema = z.object({
|
||||
conversacionId: z.string().min(10),
|
||||
vendedorId: z.string().min(10),
|
||||
})
|
||||
|
||||
export async function reasignarWacrmEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = reasignarSchema.parse(req.body)
|
||||
await reasignarConversacion(body)
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'REASIGNAR_WACRM', 'conversacion', body.conversacionId, body.vendedorId)
|
||||
res.json({ ok: true })
|
||||
}
|
||||
|
||||
/** Dispara el round-robin a demanda (admin) — útil para pruebas. */
|
||||
export async function roundRobinWacrmEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await ejecutarRoundRobinWacrm())
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { crearApp } from './app.js'
|
||||
import { config } from './config/index.js'
|
||||
import { automatizacionesService } from './services/automatizaciones.service.js'
|
||||
import * as configService from './services/config.service.js'
|
||||
import { ejecutarRoundRobinWacrm } from './services/wacrm.service.js'
|
||||
import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.service.js'
|
||||
|
||||
const app = crearApp()
|
||||
@@ -64,6 +65,18 @@ if (config.automatizaciones.enabled) {
|
||||
})
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Recordatorios de cobranza activos (cron: "${config.recordatoriosCobranza.cron}")`)
|
||||
|
||||
// Round-robin de conversaciones entrantes de WhatsApp (WA CRM). Cada 30 s
|
||||
// revisa conversaciones sin agente de la cuenta configurada y las turna
|
||||
// entre los vendedores vinculados. Silencioso cuando no hay nada que hacer.
|
||||
cron.schedule('*/30 * * * * *', () => {
|
||||
void ejecutarRoundRobinWacrm().catch((err: unknown) => {
|
||||
// 503 = wacrm aún no configurado: se espera, no es error de log ruidoso
|
||||
const e = err as { status?: number }
|
||||
if (e?.status && e.status < 500) return
|
||||
console.error('[cron wacrm] error en round-robin WhatsApp:', err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Precarga de configuraciones desde DB (modo_local_forzado, etc.)
|
||||
|
||||
@@ -96,6 +96,14 @@ import {
|
||||
subirDocumento,
|
||||
} from '../controllers/ai.controller.js'
|
||||
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
|
||||
import {
|
||||
configurarWacrmEndpoint,
|
||||
estadoWacrmEndpoint,
|
||||
miBandejaEndpoint,
|
||||
reasignarWacrmEndpoint,
|
||||
requireRolWacrmAdmin,
|
||||
roundRobinWacrmEndpoint,
|
||||
} from '../controllers/wacrm.controller.js'
|
||||
import { resumenReportes, scoringEndpoint, moraEndpoint } from '../controllers/reportes.controller.js'
|
||||
import {
|
||||
requireRolReportes,
|
||||
@@ -286,6 +294,15 @@ apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
|
||||
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
|
||||
// Cambio de fase del pipeline Enkontrol desde "Mis apartados" (Mi día)
|
||||
apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint)
|
||||
|
||||
// ── Módulo WhatsApp (WA CRM) ─────────────────────────────────────────
|
||||
// Bandeja del vendedor logueado (vínculo Usuario.wacrmUserId)
|
||||
apiRouter.get('/wacrm/mi-bandeja', miBandejaEndpoint)
|
||||
// Administración del round-robin (admin/manager)
|
||||
apiRouter.get('/wacrm/estado', requireRolWacrmAdmin, estadoWacrmEndpoint)
|
||||
apiRouter.post('/wacrm/config', requireRolWacrmAdmin, configurarWacrmEndpoint)
|
||||
apiRouter.post('/wacrm/reasignar', requireRolWacrmAdmin, reasignarWacrmEndpoint)
|
||||
apiRouter.post('/wacrm/round-robin', requireRolWacrmAdmin, roundRobinWacrmEndpoint)
|
||||
// Precarga de documentos del expediente desde "Mis apartados": se guardan en
|
||||
// el cliente ligado al apartado (se crea la ficha mínima si aún no existe)
|
||||
apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado)
|
||||
|
||||
155
api-gateway/src/services/wacrm.service.test.ts
Normal file
155
api-gateway/src/services/wacrm.service.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Tests de integración del servicio wacrm contra el Supabase real.
|
||||
*
|
||||
* Se saltan automáticamente si WACRM_PG_URL no está definida (CI/entornos
|
||||
* sin wacrm). Crean datos de prueba en la base de wacrm y los limpian al
|
||||
* final (afterAll) aunque fallen las assertions.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { Pool } from 'pg'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { prisma } from './prisma.js'
|
||||
import { ejecutarRoundRobinWacrm, miBandeja, setWacrmPoolForTesting } from './wacrm.service.js'
|
||||
import { actualizarConfigs, obtenerConfig } from './config.service.js'
|
||||
|
||||
const HABILITADO = Boolean(process.env.WACRM_PG_URL)
|
||||
|
||||
describe.skipIf(!HABILITADO)('wacrm.service (integración)', () => {
|
||||
let pool: Pool
|
||||
let cuentaId: string
|
||||
let authUsers: string[] = []
|
||||
const idsUsuariosCrm: string[] = []
|
||||
const idsConversaciones: string[] = []
|
||||
const CLAVES_CONFIG = ['wacrm_cuenta_id', 'wacrm_rr_activo', 'wacrm_rr_idx']
|
||||
const configPrev: Record<string, string | null> = {}
|
||||
|
||||
beforeAll(async () => {
|
||||
pool = new Pool({ connectionString: process.env.WACRM_PG_URL, max: 2 })
|
||||
setWacrmPoolForTesting(pool as unknown as never)
|
||||
|
||||
// Usuarios auth de prueba (2) + cuenta + perfiles agente
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const uid = randomUUID()
|
||||
authUsers.push(uid)
|
||||
await pool.query(
|
||||
`INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, created_at, updated_at)
|
||||
VALUES ($1, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', $2, 'x', now(), now())`,
|
||||
[uid, `wacrm-test-${i}-${randomUUID()}@test.local`],
|
||||
)
|
||||
}
|
||||
// handle_new_user crea automáticamente una cuenta personal + profile por
|
||||
// usuario: reusamos la cuenta del primer usuario y movemos al segundo.
|
||||
const { rows: cuentas } = await pool.query(
|
||||
`SELECT id FROM accounts WHERE owner_user_id = $1`,
|
||||
[authUsers[0]],
|
||||
)
|
||||
cuentaId = cuentas[0].id
|
||||
// Primero mover el profile ( CASCADE del account borraría el profile ),
|
||||
// luego eliminar la cuenta personal sobrante del segundo usuario.
|
||||
await pool.query(
|
||||
`UPDATE profiles SET account_id = $1, account_role = 'agent' WHERE user_id = $2`,
|
||||
[cuentaId, authUsers[1]],
|
||||
)
|
||||
await pool.query(`DELETE FROM accounts WHERE owner_user_id = $1`, [authUsers[1]])
|
||||
// Vendedores CRM vinculados a esos usuarios auth
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const u = await prisma.usuario.create({
|
||||
data: {
|
||||
username: `wacrmtest${i}-${randomUUID().slice(0, 8)}`,
|
||||
nombre: `Vendedor Test ${i}`,
|
||||
passwordHash: 'x',
|
||||
rol: 'vendedor',
|
||||
wacrmUserId: authUsers[i],
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
idsUsuariosCrm.push(u.id)
|
||||
}
|
||||
// Guarda config previa y activa round-robin sobre la cuenta de prueba
|
||||
for (const k of CLAVES_CONFIG) configPrev[k] = await obtenerConfig(k)
|
||||
await actualizarConfigs({ 'wacrm_cuenta_id': cuentaId, 'wacrm_rr_activo': '1', 'wacrm_rr_idx': '0' })
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.query('DELETE FROM conversations WHERE account_id = $1', [cuentaId])
|
||||
await pool.query('DELETE FROM contacts WHERE account_id = $1', [cuentaId])
|
||||
await pool.query('DELETE FROM profiles WHERE user_id = ANY($1)', [authUsers])
|
||||
await pool.query('DELETE FROM accounts WHERE id = $1', [cuentaId])
|
||||
await pool.query('DELETE FROM auth.users WHERE id = ANY($1)', [authUsers])
|
||||
await prisma.usuario.deleteMany({ where: { id: { in: idsUsuariosCrm } } })
|
||||
const restaurar: Record<string, string> = {}
|
||||
for (const k of CLAVES_CONFIG) if (configPrev[k] !== null) restaurar[k] = configPrev[k] as string
|
||||
if (Object.keys(restaurar).length > 0) await actualizarConfigs(restaurar)
|
||||
for (const k of CLAVES_CONFIG) {
|
||||
if (configPrev[k] === null) await prisma.configuracionSistema.deleteMany({ where: { clave: k } })
|
||||
}
|
||||
setWacrmPoolForTesting(null)
|
||||
await pool.end()
|
||||
}, 60_000)
|
||||
|
||||
async function crearConversacion(telefono: string): Promise<string> {
|
||||
const contactId = randomUUID()
|
||||
await pool.query(
|
||||
`INSERT INTO contacts (id, user_id, phone, name, account_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, now(), now())`,
|
||||
[contactId, authUsers[0], telefono, `Contacto ${telefono}`, cuentaId],
|
||||
)
|
||||
const convId = randomUUID()
|
||||
await pool.query(
|
||||
`INSERT INTO conversations (id, user_id, contact_id, status, account_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'open', $4, now(), now())`,
|
||||
[convId, authUsers[0], contactId, cuentaId],
|
||||
)
|
||||
idsConversaciones.push(convId)
|
||||
return convId
|
||||
}
|
||||
|
||||
it('round-robin turna conversaciones sin asignar entre los vendedores', async () => {
|
||||
await crearConversacion('521550000001')
|
||||
await crearConversacion('521550000002')
|
||||
await crearConversacion('521550000003')
|
||||
|
||||
const r = await ejecutarRoundRobinWacrm()
|
||||
expect(r.asignados).toBe(3)
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT assigned_agent_id FROM conversations WHERE account_id = $1 ORDER BY created_at`,
|
||||
[cuentaId],
|
||||
)
|
||||
const asignados = rows.map((r2: { assigned_agent_id: string }) => r2.assigned_agent_id)
|
||||
// 3 conversaciones, 2 vendedores: reparte 2-1 y todos quedan asignados
|
||||
expect(asignados.every((a: string | null) => a !== null)).toBe(true)
|
||||
expect(new Set(asignados).size).toBe(2)
|
||||
// Segunda pasada: nada nuevo que asignar
|
||||
const r2 = await ejecutarRoundRobinWacrm()
|
||||
expect(r2.asignados).toBe(0)
|
||||
}, 30_000)
|
||||
|
||||
it('miBandeja devuelve solo las conversaciones del vendedor', async () => {
|
||||
const bandeja0 = await miBandeja(idsUsuariosCrm[0])
|
||||
const bandeja1 = await miBandeja(idsUsuariosCrm[1])
|
||||
expect(bandeja0.vinculado).toBe(true)
|
||||
expect(bandeja1.vinculado).toBe(true)
|
||||
expect(bandeja0.conversaciones.length + bandeja1.conversaciones.length).toBe(3)
|
||||
// Sin solapamiento
|
||||
const ids0 = new Set(bandeja0.conversaciones.map((c) => c.id))
|
||||
for (const c of bandeja1.conversaciones) expect(ids0.has(c.id)).toBe(false)
|
||||
expect(bandeja0.conversaciones[0]?.contacto.telefono).toBeTruthy()
|
||||
}, 30_000)
|
||||
|
||||
it('miBandeja marca como no vinculado a un usuario sin wacrmUserId', async () => {
|
||||
const u = await prisma.usuario.create({
|
||||
data: {
|
||||
username: `wacrmsinvinc-${randomUUID().slice(0, 8)}`,
|
||||
nombre: 'Sin Vinculo',
|
||||
passwordHash: 'x',
|
||||
rol: 'vendedor',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
idsUsuariosCrm.push(u.id)
|
||||
const bandeja = await miBandeja(u.id)
|
||||
expect(bandeja.vinculado).toBe(false)
|
||||
expect(bandeja.conversaciones).toEqual([])
|
||||
}, 30_000)
|
||||
})
|
||||
284
api-gateway/src/services/wacrm.service.ts
Normal file
284
api-gateway/src/services/wacrm.service.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Servicio de integración con WA CRM (wacrm — WhatsApp).
|
||||
*
|
||||
* wacrm vive en su propia base (Supabase Postgres self-hosted, esquema
|
||||
* public) y comparte usuarios con el CRM mediante la columna
|
||||
* `Usuario.wacrmUserId` (id de auth.users en Supabase).
|
||||
*
|
||||
* Responsabilidades:
|
||||
* - Round-robin: cada conversación entrante sin `assigned_agent_id` se
|
||||
* asigna al siguiente vendedor del pool (rol 'vendedor' vinculado Y
|
||||
* miembro agente/admin/owner de la cuenta wacrm configurada).
|
||||
* - Bandeja: exponer "mis conversaciones" al vendedor logueado.
|
||||
* - Admin: estado general, configuración de cuenta/activo y reasignación.
|
||||
*
|
||||
* La conexión es lazy: si WACRM_PG_URL no está definida o la base no
|
||||
* responde, las funciones lanzan AppError 503 (el resto del CRM sigue
|
||||
* funcionando igual).
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg'
|
||||
import { prisma } from './prisma.js'
|
||||
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
|
||||
import { obtenerConfig, actualizarConfigs } from './config.service.js'
|
||||
|
||||
// ── Config en ConfiguracionSistema ─────────────────────────────────────
|
||||
export const CLAVE_CUENTA = 'wacrm_cuenta_id'
|
||||
export const CLAVE_RR_ACTIVO = 'wacrm_rr_activo'
|
||||
const CLAVE_RR_IDX = 'wacrm_rr_idx'
|
||||
|
||||
// ── Pool lazy ──────────────────────────────────────────────────────────
|
||||
let pool: Pool | null = null
|
||||
let poolUrlUsada = ''
|
||||
|
||||
function obtenerPool(): Pool {
|
||||
const url = process.env.WACRM_PG_URL ?? ''
|
||||
if (!url) throw new AppError(503, 'WA CRM no configurado (falta WACRM_PG_URL)')
|
||||
if (!pool || poolUrlUsada !== url) {
|
||||
pool?.end().catch(() => undefined)
|
||||
pool = new Pool({ connectionString: url, max: 3, idleTimeoutMillis: 30_000 })
|
||||
poolUrlUsada = url
|
||||
// No dejar que un error de conexión tumbe el proceso
|
||||
pool.on('error', (err) => console.error('[wacrm] error en pool pg:', err.message))
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
/** Para tests: inyectar un pool falso / resetear al real. */
|
||||
export function setWacrmPoolForTesting(fake: Pool | null): void {
|
||||
pool = fake
|
||||
// Marcar la URL como "ya cargada" para que obtenerPool() no cierre el pool
|
||||
// inyectado al comparar contra la URL del entorno.
|
||||
poolUrlUsada = fake ? (process.env.WACRM_PG_URL ?? '') : ''
|
||||
}
|
||||
|
||||
interface ConversacionBandeja {
|
||||
id: string
|
||||
status: string
|
||||
lastMessage: string | null
|
||||
lastMessageAt: string | null
|
||||
unreadCount: number
|
||||
contacto: { nombre: string | null; telefono: string }
|
||||
}
|
||||
|
||||
// ── Helpers internos ───────────────────────────────────────────────────
|
||||
|
||||
/** Vendedores CRM vinculados a wacrm que además son agentes de la cuenta. */
|
||||
async function poolVendedoresCuenta(cuentaId: string) {
|
||||
const vendedores = await prisma.usuario.findMany({
|
||||
where: { rol: 'vendedor', activo: true, wacrmUserId: { not: null } },
|
||||
orderBy: { nombre: 'asc' },
|
||||
select: { id: true, nombre: true, wacrmUserId: true },
|
||||
})
|
||||
if (vendedores.length === 0) return []
|
||||
const ids = vendedores.map((v) => v.wacrmUserId as string)
|
||||
const { rows } = await obtenerPool().query(
|
||||
`SELECT user_id FROM profiles
|
||||
WHERE account_id = $1 AND account_role IN ('owner','admin','agent')
|
||||
AND user_id = ANY($2::uuid[])`,
|
||||
[cuentaId, ids],
|
||||
)
|
||||
const miembros = new Set(rows.map((r: { user_id: string }) => r.user_id))
|
||||
return vendedores.filter((v) => miembros.has(v.wacrmUserId as string))
|
||||
}
|
||||
|
||||
// ── Round-robin ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Asigna las conversaciones sin agente de la cuenta configurada,
|
||||
* turnándose entre los vendedores del pool. Devuelve cuántas asignó.
|
||||
*/
|
||||
export async function ejecutarRoundRobinWacrm(): Promise<{ asignados: number }> {
|
||||
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
|
||||
const activo = (await obtenerConfig(CLAVE_RR_ACTIVO)) === '1'
|
||||
if (!activo || !cuentaId) return { asignados: 0 }
|
||||
|
||||
const poolVendedores = await poolVendedoresCuenta(cuentaId)
|
||||
if (poolVendedores.length === 0) return { asignados: 0 }
|
||||
|
||||
const db = obtenerPool()
|
||||
const { rows: sinAsignar } = await db.query(
|
||||
`SELECT id FROM conversations
|
||||
WHERE account_id = $1 AND assigned_agent_id IS NULL AND status = 'open'
|
||||
ORDER BY created_at ASC LIMIT 50`,
|
||||
[cuentaId],
|
||||
)
|
||||
|
||||
let idx = Number.parseInt((await obtenerConfig(CLAVE_RR_IDX)) ?? '0', 10)
|
||||
if (!Number.isFinite(idx) || idx < 0) idx = 0
|
||||
|
||||
let asignados = 0
|
||||
for (const conv of sinAsignar as { id: string }[]) {
|
||||
const vend = poolVendedores[idx % poolVendedores.length]!
|
||||
idx += 1
|
||||
const r = await db.query(
|
||||
`UPDATE conversations SET assigned_agent_id = $2, updated_at = now()
|
||||
WHERE id = $1 AND assigned_agent_id IS NULL`,
|
||||
[conv.id, vend.wacrmUserId],
|
||||
)
|
||||
if (r.rowCount === 1) {
|
||||
asignados += 1
|
||||
console.log(`[wacrm round-robin] conversación ${conv.id} → ${vend.nombre}`)
|
||||
}
|
||||
}
|
||||
if (asignados > 0 || sinAsignar.length > 0) {
|
||||
await actualizarConfigs({ [CLAVE_RR_IDX]: String(idx) })
|
||||
}
|
||||
return { asignados }
|
||||
}
|
||||
|
||||
// ── Bandeja del vendedor ───────────────────────────────────────────────
|
||||
|
||||
/** Conversaciones de WhatsApp asignadas al usuario del CRM (por su vínculo). */
|
||||
export async function miBandeja(usuarioCrmId: string): Promise<{
|
||||
vinculado: boolean
|
||||
conversaciones: ConversacionBandeja[]
|
||||
}> {
|
||||
const usuario = await prisma.usuario.findUnique({ where: { id: usuarioCrmId } })
|
||||
if (!usuario?.wacrmUserId) return { vinculado: false, conversaciones: [] }
|
||||
|
||||
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
|
||||
const params: string[] = [usuario.wacrmUserId]
|
||||
let filtroCuenta = ''
|
||||
if (cuentaId) {
|
||||
params.push(cuentaId)
|
||||
filtroCuenta = 'AND c.account_id = $2'
|
||||
}
|
||||
const { rows } = await obtenerPool().query(
|
||||
`SELECT c.id, c.status, c.last_message_text, c.last_message_at, c.unread_count,
|
||||
ct.name AS contacto_nombre, ct.phone AS contacto_telefono
|
||||
FROM conversations c JOIN contacts ct ON ct.id = c.contact_id
|
||||
WHERE c.assigned_agent_id = $1 ${filtroCuenta}
|
||||
ORDER BY c.last_message_at DESC NULLS LAST, c.created_at DESC
|
||||
LIMIT 100`,
|
||||
params,
|
||||
)
|
||||
return {
|
||||
vinculado: true,
|
||||
conversaciones: rows.map((r) => ({
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
lastMessage: r.last_message_text,
|
||||
lastMessageAt: r.last_message_at ? new Date(r.last_message_at).toISOString() : null,
|
||||
unreadCount: r.unread_count ?? 0,
|
||||
contacto: { nombre: r.contacto_nombre, telefono: r.contacto_telefono },
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin: estado / configuración / reasignación ───────────────────────
|
||||
|
||||
export async function estadoWacrm(): Promise<{
|
||||
cuentaId: string | null
|
||||
roundRobinActivo: boolean
|
||||
sinAsignar: number
|
||||
totalConversaciones: number
|
||||
vendedores: Array<{
|
||||
id: string
|
||||
nombre: string
|
||||
wacrmUserId: string
|
||||
esMiembro: boolean
|
||||
asignadas: number
|
||||
}>
|
||||
}> {
|
||||
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
|
||||
const roundRobinActivo = (await obtenerConfig(CLAVE_RR_ACTIVO)) === '1'
|
||||
if (!cuentaId) {
|
||||
return { cuentaId: null, roundRobinActivo, sinAsignar: 0, totalConversaciones: 0, vendedores: [] }
|
||||
}
|
||||
const db = obtenerPool()
|
||||
const { rows: conteos } = await db.query(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE assigned_agent_id IS NULL AND status = 'open') AS sin_asignar,
|
||||
COUNT(*) AS total
|
||||
FROM conversations WHERE account_id = $1`,
|
||||
[cuentaId],
|
||||
)
|
||||
const candidatos = await prisma.usuario.findMany({
|
||||
where: { rol: 'vendedor', activo: true, wacrmUserId: { not: null } },
|
||||
orderBy: { nombre: 'asc' },
|
||||
select: { id: true, nombre: true, wacrmUserId: true },
|
||||
})
|
||||
const { rows: miembros } = await db.query(
|
||||
`SELECT user_id FROM profiles
|
||||
WHERE account_id = $1 AND account_role IN ('owner','admin','agent')
|
||||
AND user_id = ANY($2::uuid[])`,
|
||||
[cuentaId, candidatos.map((v) => v.wacrmUserId as string)],
|
||||
)
|
||||
const setMiembros = new Set(miembros.map((r: { user_id: string }) => r.user_id))
|
||||
const { rows: asignadas } = await db.query(
|
||||
`SELECT assigned_agent_id AS uid, COUNT(*)::int AS n
|
||||
FROM conversations WHERE account_id = $1 AND assigned_agent_id IS NOT NULL
|
||||
GROUP BY assigned_agent_id`,
|
||||
[cuentaId],
|
||||
)
|
||||
const mapAsignadas = new Map(asignadas.map((r: { uid: string; n: number }) => [r.uid, r.n]))
|
||||
return {
|
||||
cuentaId,
|
||||
roundRobinActivo,
|
||||
sinAsignar: Number(conteos[0]?.sin_asignar ?? 0),
|
||||
totalConversaciones: Number(conteos[0]?.total ?? 0),
|
||||
vendedores: candidatos.map((v) => ({
|
||||
id: v.id,
|
||||
nombre: v.nombre,
|
||||
wacrmUserId: v.wacrmUserId as string,
|
||||
esMiembro: setMiembros.has(v.wacrmUserId as string),
|
||||
asignadas: mapAsignadas.get(v.wacrmUserId as string) ?? 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function configurarWacrm(datos: {
|
||||
cuentaId?: string | undefined
|
||||
activo?: boolean | undefined
|
||||
}): Promise<{ cuentaId: string | null; activo: boolean }> {
|
||||
const cambios: Record<string, string> = {}
|
||||
if (datos.cuentaId !== undefined) {
|
||||
if (datos.cuentaId && !/^[0-9a-f-]{36}$/i.test(datos.cuentaId)) {
|
||||
throw new BadRequestError('cuentaId debe ser un UUID válido (o vacío para quitar)')
|
||||
}
|
||||
if (datos.cuentaId) {
|
||||
// Verificar que la cuenta exista en wacrm antes de guardarla
|
||||
const { rows } = await obtenerPool().query(`SELECT id FROM accounts WHERE id = $1`, [
|
||||
datos.cuentaId,
|
||||
])
|
||||
if (rows.length === 0) throw new NotFoundError('La cuenta no existe en WA CRM')
|
||||
}
|
||||
cambios[CLAVE_CUENTA] = datos.cuentaId
|
||||
}
|
||||
if (datos.activo !== undefined) cambios[CLAVE_RR_ACTIVO] = datos.activo ? '1' : '0'
|
||||
if (Object.keys(cambios).length > 0) await actualizarConfigs(cambios)
|
||||
const [cuentaId, activo] = await Promise.all([
|
||||
obtenerConfig(CLAVE_CUENTA),
|
||||
obtenerConfig(CLAVE_RR_ACTIVO),
|
||||
])
|
||||
return { cuentaId, activo: activo === '1' }
|
||||
}
|
||||
|
||||
export async function reasignarConversacion(datos: {
|
||||
conversacionId: string
|
||||
vendedorId: string
|
||||
}): Promise<void> {
|
||||
if (!/^[0-9a-f-]{36}$/i.test(datos.conversacionId)) {
|
||||
throw new BadRequestError('conversacionId debe ser un UUID')
|
||||
}
|
||||
const vendedor = await prisma.usuario.findFirst({
|
||||
where: { id: datos.vendedorId, rol: 'vendedor', activo: true },
|
||||
})
|
||||
if (!vendedor?.wacrmUserId) throw new NotFoundError('Vendedor sin vínculo a WA CRM')
|
||||
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
|
||||
const db = obtenerPool()
|
||||
if (cuentaId) {
|
||||
const { rows } = await db.query(
|
||||
`SELECT 1 FROM profiles WHERE account_id = $1 AND user_id = $2
|
||||
AND account_role IN ('owner','admin','agent')`,
|
||||
[cuentaId, vendedor.wacrmUserId],
|
||||
)
|
||||
if (rows.length === 0) throw new BadRequestError('El vendedor no es miembro de la cuenta WA CRM')
|
||||
}
|
||||
const r = await db.query(
|
||||
`UPDATE conversations SET assigned_agent_id = $2, updated_at = now() WHERE id = $1`,
|
||||
[datos.conversacionId, vendedor.wacrmUserId],
|
||||
)
|
||||
if (r.rowCount === 0) throw new NotFoundError('Conversación no encontrada')
|
||||
}
|
||||
Reference in New Issue
Block a user