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:
2026-09-30 00:30:17 +00:00
parent cb1c36b669
commit e3b5660769
28 changed files with 5744 additions and 1 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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'),

View 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())
}

View File

@@ -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.)

View File

@@ -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)

View 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)
})

View 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')
}