feat(api): automatizaciones Monday — round-robin de leads, recordatorio inactividad 10d, webhooks reales y polling cron
- Servicio de automatizaciones con estado persistido en SQLite (índice RR, dedup de recordatorios) - Webhook Monday procesa create_item del board Leads y asigna vendedor - Crons: polling cada 5 min (respaldo sin webhook público) + escaneo diario 9am - Endpoints admin: /api/automatizaciones/estado, /round-robin/run, /inactividad/preview - 15 tests nuevos (70/70 verdes)
This commit is contained in:
@@ -41,7 +41,16 @@ npm test # vitest: fórmulas del cotizador (fixture U-101), cobranza, monday.s
|
||||
| POST | `/api/planes/:id/abonos` | Aplica abono (estricto al centavo) |
|
||||
| POST | `/api/planes/:id/notas` | Agrega nota a la bitácora |
|
||||
| POST | `/api/planes/:id/cancelar` | Cancela el plan |
|
||||
| POST | `/api/webhooks/monday` | Handshake `challenge` + log de eventos |
|
||||
| POST | `/api/webhooks/monday` | Handshake `challenge`; `create_item` en Leads dispara round-robin |
|
||||
| GET | `/api/automatizaciones/estado` | Índice RR actual, vendedores, último escaneo |
|
||||
| POST | `/api/automatizaciones/round-robin/run` | Pasada manual de asignación round-robin |
|
||||
| GET | `/api/automatizaciones/inactividad/preview` | Leads que serían recordados hoy (solo lectura) |
|
||||
|
||||
## Automatizaciones de Monday (tablero Leads)
|
||||
|
||||
- **Round-robin de vendedores**: leads sin persona en la columna people `Vendedor` reciben el siguiente vendedor de la lista rotativa. La lista viene de `MONDAY_RR_USER_IDS` (coma-separados) o, si está vacía, de los usuarios no-guest del workspace (cacheados en BD). El índice se persiste en la tabla `AutomatizacionState` (sobrevive reinicios).
|
||||
- **Recordatorio de inactividad**: leads con estado no terminal (`DESCARTADO` es el único terminal) y más de `INACTIVIDAD_DIAS` (default 10) sin actualizarse reciben un update en el item. Dedup por día: clave `recordatorio:<itemId>:<fecha>` en `AutomatizacionState`.
|
||||
- **Disparadores**: webhook `POST /api/webhooks/monday` (evento `create_item` en el board Leads) y, como respaldo mientras no haya URL pública, crons con node-cron: `POLL_CRON` (default `*/5 * * * *`) para round-robin y `0 9 * * *` para inactividad. Los crons se apagan con `MONDAY_AUTOMATIONS_ENABLED=false`.
|
||||
|
||||
## Notas de arquitectura
|
||||
|
||||
@@ -49,7 +58,7 @@ npm test # vitest: fórmulas del cotizador (fixture U-101), cobranza, monday.s
|
||||
- **Cálculos del cotizador** (`src/services/cotizacion.service.ts`): port exacto del Excel de producción (quirks incluidos: Infonavit usa precio de lista en mensualidades; enganche bancario sobre precio lista). NO corregir.
|
||||
- **Cobranza** (`src/services/cobranza.service.ts`): espejo de la lógica del frontend; los controllers mapean Prisma → dominio, ejecutan las funciones puras y persisten.
|
||||
- **Dinero como `Float`** por ahora — TODO prod: `Decimal` en Postgres.
|
||||
- **Webhooks de Monday no están firmados** → el payload se trata como no confiable; antes de actuar, re-consultar vía API (por ahora solo se loggea).
|
||||
- **Webhooks de Monday no están firmados** → el payload se trata como no confiable; el evento solo es un disparador y el round-robin re-consulta el tablero vía API antes de escribir.
|
||||
|
||||
## TODO producción (pendientes deliberados)
|
||||
|
||||
|
||||
18
api-gateway/package-lock.json
generated
18
api-gateway/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^5.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.6.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -20,6 +21,7 @@
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.15.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"prisma": "^6.14.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.9.2",
|
||||
@@ -1045,6 +1047,13 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-cron": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
|
||||
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
@@ -2244,6 +2253,15 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz",
|
||||
"integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch-native": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^5.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.6.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -27,6 +28,7 @@
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.15.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"prisma": "^6.14.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.9.2",
|
||||
|
||||
@@ -112,6 +112,14 @@ model AplicacionPago {
|
||||
@@index([pagoId])
|
||||
}
|
||||
|
||||
// Estado persistente de las automatizaciones de Monday (round-robin, recordatorios).
|
||||
// Clave-valor genérico: 'rr:indice', 'rr:vendedores', 'recordatorio:<itemId>:<fecha>', etc.
|
||||
model AutomatizacionState {
|
||||
clave String @id
|
||||
valor String
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model NotaPlan {
|
||||
id String @id @default(uuid())
|
||||
planId String
|
||||
|
||||
@@ -29,6 +29,15 @@ export const config = {
|
||||
ventas: env('MONDAY_BOARD_VENTAS'),
|
||||
},
|
||||
},
|
||||
automatizaciones: {
|
||||
// 'false' desactiva los crons (útil en tests/CI); los endpoints admin siguen vivos
|
||||
enabled: env('MONDAY_AUTOMATIONS_ENABLED', 'true') !== 'false',
|
||||
// IDs de usuario Monday separados por coma; vacío = auto-detectar usuarios no-guest del workspace
|
||||
rrUserIds: env('MONDAY_RR_USER_IDS', ''),
|
||||
inactividadDias: Number(env('INACTIVIDAD_DIAS', '10')),
|
||||
pollCron: env('POLL_CRON', '*/5 * * * *'),
|
||||
recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'),
|
||||
},
|
||||
} as const
|
||||
|
||||
export type AppConfig = typeof config
|
||||
|
||||
17
api-gateway/src/controllers/automatizaciones.controller.ts
Normal file
17
api-gateway/src/controllers/automatizaciones.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { automatizacionesService } from '../services/automatizaciones.service.js'
|
||||
|
||||
/** GET /api/automatizaciones/estado — índice RR, vendedores, último escaneo */
|
||||
export async function estadoAutomatizaciones(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await automatizacionesService.estado())
|
||||
}
|
||||
|
||||
/** POST /api/automatizaciones/round-robin/run — pasada manual de asignación */
|
||||
export async function correrRoundRobin(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await automatizacionesService.runRoundRobin())
|
||||
}
|
||||
|
||||
/** GET /api/automatizaciones/inactividad/preview — leads que serían recordados hoy (sin escribir) */
|
||||
export async function previewInactividad(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await automatizacionesService.escaneoInactividad({ preview: true }))
|
||||
}
|
||||
@@ -1,22 +1,27 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { config } from '../config/index.js'
|
||||
import { automatizacionesService } from '../services/automatizaciones.service.js'
|
||||
|
||||
const eventoSchema = z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
boardId: z.union([z.string(), z.number()]).optional(),
|
||||
type: z.string(),
|
||||
boardId: z.union([z.string(), z.number()]),
|
||||
itemId: z.union([z.string(), z.number()]).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const payloadSchema = z.object({ event: eventoSchema }).passthrough()
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/monday
|
||||
* Handshake: Monday envía { challenge } al registrar el webhook y exige que
|
||||
* se responda tal cual. Para eventos reales solo se loggea y se responde 200.
|
||||
* se responda tal cual. Eventos reales: se responde 200 de inmediato y se
|
||||
* procesan en segundo plano.
|
||||
*
|
||||
* OJO: Monday NO firma sus webhooks → el payload no es confiable. Antes de
|
||||
* actuar sobre un evento hay que re-consultar el estado vía API (por ahora
|
||||
* solo se registra el evento).
|
||||
* OJO: Monday NO firma sus webhooks → el payload no es confiable. Por eso el
|
||||
* evento solo se usa como DISPARADOR: el round-robin re-consulta el tablero
|
||||
* vía API antes de escribir nada.
|
||||
*/
|
||||
export function webhookMonday(req: Request, res: Response): void {
|
||||
const body = req.body as Record<string, unknown>
|
||||
@@ -26,11 +31,34 @@ export function webhookMonday(req: Request, res: Response): void {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = eventoSchema.safeParse(body)
|
||||
if (parsed.success) {
|
||||
const { type, boardId, itemId } = parsed.data
|
||||
const parsed = payloadSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[webhook monday] tipo=${type ?? 'desconocido'} board=${boardId ?? '?'} item=${itemId ?? '?'}`)
|
||||
console.log('[webhook monday] payload sin estructura de evento reconocida; se ignora')
|
||||
res.sendStatus(200)
|
||||
return
|
||||
}
|
||||
|
||||
const { type, boardId, itemId } = parsed.data.event
|
||||
const esBoardLeads = String(boardId) === config.monday.boards.leads
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[webhook monday] tipo=${type} board=${boardId} item=${itemId ?? '?'}`)
|
||||
|
||||
if (type === 'create_item' && esBoardLeads && itemId !== undefined) {
|
||||
const id = String(itemId)
|
||||
// Fire-and-forget: la respuesta 200 no espera a que termine la asignación
|
||||
void automatizacionesService
|
||||
.runRoundRobin(id)
|
||||
.then((r) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[webhook monday] round-robin item=${id}: ${r.asignados} asignado(s)`)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[webhook monday] error en round-robin item=${id}:`, err)
|
||||
})
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,41 @@
|
||||
import cron from 'node-cron'
|
||||
import { crearApp } from './app.js'
|
||||
import { config } from './config/index.js'
|
||||
import { automatizacionesService } from './services/automatizaciones.service.js'
|
||||
|
||||
const app = crearApp()
|
||||
|
||||
// Crons de respaldo (el webhook público aún no existe: no hay SSL ni DNS).
|
||||
// Se desactivan con MONDAY_AUTOMATIONS_ENABLED=false.
|
||||
if (config.automatizaciones.enabled) {
|
||||
// Polling: asigna vendedor (round-robin) a leads nuevos sin persona asignada
|
||||
cron.schedule(config.automatizaciones.pollCron, () => {
|
||||
automatizacionesService
|
||||
.runRoundRobin()
|
||||
.then((r) => {
|
||||
if (r.asignados > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[cron poll] round-robin: ${r.asignados} lead(s) asignado(s)`)
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => console.error('[cron poll] error en round-robin:', err))
|
||||
})
|
||||
|
||||
// Escaneo diario de leads inactivos (recordatorio vía update en el item)
|
||||
cron.schedule(config.automatizaciones.recordatorioCron, () => {
|
||||
automatizacionesService
|
||||
.escaneoInactividad({ preview: false })
|
||||
.then((r) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[cron inactividad] ${r.recordatoriosEnviados} recordatorio(s) enviado(s)`)
|
||||
})
|
||||
.catch((err: unknown) => console.error('[cron inactividad] error en escaneo:', err))
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Automatizaciones Monday activas (poll: "${config.automatizaciones.pollCron}", inactividad: "${config.automatizaciones.recordatorioCron}")`)
|
||||
}
|
||||
|
||||
app.listen(config.port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`API Gateway FRASA escuchando en http://localhost:${config.port} (${config.nodeEnv})`)
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
registrarAbonoEndpoint,
|
||||
} from '../controllers/planes.controller.js'
|
||||
import { webhookMonday } from '../controllers/webhooks.controller.js'
|
||||
import {
|
||||
correrRoundRobin,
|
||||
estadoAutomatizaciones,
|
||||
previewInactividad,
|
||||
} from '../controllers/automatizaciones.controller.js'
|
||||
|
||||
export const apiRouter = Router()
|
||||
|
||||
@@ -49,3 +54,7 @@ apiRouter.post('/planes/:id/notas', agregarNota)
|
||||
apiRouter.post('/planes/:id/cancelar', cancelarPlan)
|
||||
|
||||
apiRouter.post('/webhooks/monday', webhookMonday)
|
||||
|
||||
apiRouter.get('/automatizaciones/estado', estadoAutomatizaciones)
|
||||
apiRouter.post('/automatizaciones/round-robin/run', correrRoundRobin)
|
||||
apiRouter.get('/automatizaciones/inactividad/preview', previewInactividad)
|
||||
|
||||
240
api-gateway/src/services/automatizaciones.service.test.ts
Normal file
240
api-gateway/src/services/automatizaciones.service.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AutomatizacionesService,
|
||||
claveRecordatorio,
|
||||
esEstadoTerminal,
|
||||
leadsInactivos,
|
||||
parseRrUserIds,
|
||||
vendedorEnTurno,
|
||||
type StateStore,
|
||||
} from './automatizaciones.service.js'
|
||||
import type { LeadMonday, UsuarioMonday } from './monday.service.js'
|
||||
|
||||
// ---------- Fixtures ----------
|
||||
|
||||
const VENDEDORES: UsuarioMonday[] = [
|
||||
{ id: 101, nombre: 'Ana', email: 'ana@x.com' },
|
||||
{ id: 102, nombre: 'Beto', email: 'beto@x.com' },
|
||||
{ id: 103, nombre: 'Carla', email: 'carla@x.com' },
|
||||
]
|
||||
|
||||
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
|
||||
return {
|
||||
id,
|
||||
nombre: `Lead ${id}`,
|
||||
updatedAt: '2026-08-01T10:00:00Z',
|
||||
estado: 'NUEVO',
|
||||
vendedorIds: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** StateStore en memoria para tests (sin Prisma) */
|
||||
function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
|
||||
const mapa = new Map<string, string>()
|
||||
return {
|
||||
mapa,
|
||||
async get(clave) {
|
||||
return mapa.get(clave) ?? null
|
||||
},
|
||||
async set(clave, valor) {
|
||||
mapa.set(clave, valor)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Monday mockeado: leads fijos, registra asignaciones y updates */
|
||||
function mondayMock(leads: LeadMonday[]) {
|
||||
return {
|
||||
asignaciones: [] as { itemId: string; userId: number }[],
|
||||
updates: [] as { itemId: string; body: string }[],
|
||||
async getLeads() {
|
||||
return leads
|
||||
},
|
||||
async asignarVendedorALead(itemId: string, userId: number) {
|
||||
this.asignaciones.push({ itemId, userId })
|
||||
},
|
||||
async crearUpdateEnItem(itemId: string, body: string) {
|
||||
this.updates.push({ itemId, body })
|
||||
return 'upd-1'
|
||||
},
|
||||
async getUsuariosWorkspace() {
|
||||
return VENDEDORES
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const crearServicio = (monday: ReturnType<typeof mondayMock>, state: StateStore) =>
|
||||
new AutomatizacionesService(monday, state)
|
||||
|
||||
// ---------- Lógica pura ----------
|
||||
|
||||
describe('parseRrUserIds', () => {
|
||||
it('parsea IDs separados por coma ignorando espacios y vacíos', () => {
|
||||
expect(parseRrUserIds(' 106735975, 106735976 ,, ')).toEqual([
|
||||
{ id: 106735975, nombre: 'Usuario 106735975', email: '' },
|
||||
{ id: 106735976, nombre: 'Usuario 106735976', email: '' },
|
||||
])
|
||||
expect(parseRrUserIds('')).toEqual([])
|
||||
})
|
||||
|
||||
it('lanza error con IDs inválidos', () => {
|
||||
expect(() => parseRrUserIds('123,abc')).toThrow(/inválido/)
|
||||
expect(() => parseRrUserIds('-5')).toThrow(/inválido/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('esEstadoTerminal', () => {
|
||||
it('reconoce DESCARTADO sin importar mayúsculas ni espacios', () => {
|
||||
expect(esEstadoTerminal('DESCARTADO')).toBe(true)
|
||||
expect(esEstadoTerminal(' descartado ')).toBe(true)
|
||||
expect(esEstadoTerminal('NUEVO')).toBe(false)
|
||||
expect(esEstadoTerminal('CONTACTADO')).toBe(false)
|
||||
expect(esEstadoTerminal('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vendedorEnTurno (rotación round-robin)', () => {
|
||||
it('rota circularmente por la lista', () => {
|
||||
expect(vendedorEnTurno(VENDEDORES, 0)?.id).toBe(101)
|
||||
expect(vendedorEnTurno(VENDEDORES, 1)?.id).toBe(102)
|
||||
expect(vendedorEnTurno(VENDEDORES, 2)?.id).toBe(103)
|
||||
expect(vendedorEnTurno(VENDEDORES, 3)?.id).toBe(101) // wraparound
|
||||
expect(vendedorEnTurno(VENDEDORES, 7)?.id).toBe(102)
|
||||
})
|
||||
|
||||
it('devuelve undefined con lista vacía', () => {
|
||||
expect(vendedorEnTurno([], 0)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('leadsInactivos', () => {
|
||||
const ahora = new Date('2026-08-22T00:00:00Z')
|
||||
|
||||
it('incluye leads activos que superan el umbral de días', () => {
|
||||
const leads = [
|
||||
lead('1', { updatedAt: '2026-08-10T00:00:00Z' }), // 12 días → inactivo (umbral 10)
|
||||
lead('2', { updatedAt: '2026-08-20T00:00:00Z' }), // 2 días → activo
|
||||
lead('3', { updatedAt: '2026-07-01T00:00:00Z', estado: 'DESCARTADO' }), // terminal → excluido
|
||||
]
|
||||
const inactivos = leadsInactivos(leads, ahora, 10)
|
||||
expect(inactivos.map((l) => l.id)).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('un lead actualizado hace exactamente N días aún no supera el umbral', () => {
|
||||
const leads = [lead('1', { updatedAt: '2026-08-12T00:00:00Z' })] // justo 10 días
|
||||
expect(leadsInactivos(leads, ahora, 10)).toEqual([])
|
||||
})
|
||||
|
||||
it('ignora fechas inválidas', () => {
|
||||
expect(leadsInactivos([lead('1', { updatedAt: 'no-es-fecha' })], ahora, 10)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('claveRecordatorio (dedup por día)', () => {
|
||||
it('una clave por lead por día', () => {
|
||||
expect(claveRecordatorio('123', new Date('2026-08-22T09:00:00Z'))).toBe('recordatorio:123:2026-08-22')
|
||||
expect(claveRecordatorio('123', new Date('2026-08-22T23:59:00Z'))).toBe('recordatorio:123:2026-08-22')
|
||||
expect(claveRecordatorio('123', new Date('2026-08-23T00:00:00Z'))).toBe('recordatorio:123:2026-08-23')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- Servicio (monday mockeado, sin red) ----------
|
||||
|
||||
describe('AutomatizacionesService.runRoundRobin', () => {
|
||||
it('asigna vendedores en rotación a leads sin persona y persiste el índice', async () => {
|
||||
const monday = mondayMock([lead('1'), lead('2'), lead('3'), lead('4')])
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
|
||||
const r1 = await svc.runRoundRobin()
|
||||
expect(r1.asignados).toBe(4)
|
||||
expect(monday.asignaciones.map((a) => a.userId)).toEqual([101, 102, 103, 101])
|
||||
expect(state.mapa.get('rr:indice')).toBe('1') // 4 asignaciones % 3 vendedores
|
||||
|
||||
// Segunda pasada con un lead nuevo: continúa donde quedó (no reinicia en 0)
|
||||
const monday2 = mondayMock([lead('5')])
|
||||
const svc2 = new AutomatizacionesService(monday2, state)
|
||||
const r2 = await svc2.runRoundRobin()
|
||||
expect(r2.asignados).toBe(1)
|
||||
expect(monday2.asignaciones[0]?.userId).toBe(102)
|
||||
expect(state.mapa.get('rr:indice')).toBe('2')
|
||||
})
|
||||
|
||||
it('omite leads con vendedor ya asignado o en estado terminal', async () => {
|
||||
const monday = mondayMock([
|
||||
lead('1'),
|
||||
lead('2', { vendedorIds: [101] }), // ya asignado
|
||||
lead('3', { estado: 'DESCARTADO' }), // terminal
|
||||
])
|
||||
const svc = crearServicio(monday, stateEnMemoria())
|
||||
const r = await svc.runRoundRobin()
|
||||
expect(r.asignados).toBe(1)
|
||||
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101 }])
|
||||
})
|
||||
|
||||
it('con soloItemId solo evalúa ese item (ruta del webhook create_item)', async () => {
|
||||
const monday = mondayMock([lead('1'), lead('2')])
|
||||
const svc = crearServicio(monday, stateEnMemoria())
|
||||
const r = await svc.runRoundRobin('2')
|
||||
expect(r.asignados).toBe(1)
|
||||
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101 }])
|
||||
})
|
||||
|
||||
it('cachea los vendedores del workspace en el estado (una sola llamada)', async () => {
|
||||
const monday = mondayMock([])
|
||||
const spy = vi.spyOn(monday, 'getUsuariosWorkspace')
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
const v1 = await svc.obtenerVendedores()
|
||||
const v2 = await svc.obtenerVendedores()
|
||||
expect(v1.map((v) => v.id)).toEqual([101, 102, 103])
|
||||
expect(v2).toEqual(v1)
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AutomatizacionesService.escaneoInactividad', () => {
|
||||
const ahora = new Date('2026-08-22T09:00:00Z')
|
||||
const leadsViejos = () => [
|
||||
lead('1', { updatedAt: '2026-08-01T00:00:00Z' }), // 21 días inactivo
|
||||
lead('2', { updatedAt: '2026-08-20T00:00:00Z' }), // activo
|
||||
]
|
||||
|
||||
it('preview: lista candidatos sin escribir en Monday ni en el estado', async () => {
|
||||
const monday = mondayMock(leadsViejos())
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
|
||||
const r = await svc.escaneoInactividad({ preview: true, ahora })
|
||||
expect(r.preview).toBe(true)
|
||||
expect(r.candidatos.map((c) => c.itemId)).toEqual(['1'])
|
||||
expect(r.candidatos[0]?.diasInactivo).toBe(21)
|
||||
expect(r.recordatoriosEnviados).toBe(0)
|
||||
expect(monday.updates).toEqual([])
|
||||
expect(state.mapa.size).toBe(0) // preview no persiste nada
|
||||
})
|
||||
|
||||
it('ejecución real: crea update, marca dedup y no repite el mismo día', async () => {
|
||||
const monday = mondayMock(leadsViejos())
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
|
||||
const r1 = await svc.escaneoInactividad({ preview: false, ahora })
|
||||
expect(r1.recordatoriosEnviados).toBe(1)
|
||||
expect(monday.updates).toHaveLength(1)
|
||||
expect(monday.updates[0]?.body).toContain('21 días')
|
||||
expect(state.mapa.get('recordatorio:1:2026-08-22')).toBeDefined()
|
||||
|
||||
// Mismo día: no repite aunque el lead siga inactivo
|
||||
const r2 = await svc.escaneoInactividad({ preview: false, ahora: new Date('2026-08-22T18:00:00Z') })
|
||||
expect(r2.recordatoriosEnviados).toBe(0)
|
||||
expect(r2.candidatos[0]?.yaRecordadoHoy).toBe(true)
|
||||
expect(monday.updates).toHaveLength(1)
|
||||
|
||||
// Al día siguiente vuelve a recordar
|
||||
const r3 = await svc.escaneoInactividad({ preview: false, ahora: new Date('2026-08-23T09:00:00Z') })
|
||||
expect(r3.recordatoriosEnviados).toBe(1)
|
||||
expect(monday.updates).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
256
api-gateway/src/services/automatizaciones.service.ts
Normal file
256
api-gateway/src/services/automatizaciones.service.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { config } from '../config/index.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
import { mondayService, type LeadMonday, type MondayService, type UsuarioMonday } from './monday.service.js'
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
/**
|
||||
* Automatizaciones sobre el tablero Leads de Monday:
|
||||
* (a) asignación round-robin de vendedores a leads sin persona asignada
|
||||
* (b) recordatorio de leads inactivos (> INACTIVIDAD_DIAS sin actualizarse)
|
||||
*
|
||||
* El estado (índice RR, cache de vendedores, recordatorios enviados) vive en la
|
||||
* tabla clave-valor AutomatizacionState para sobrevivir reinicios y no repetir
|
||||
* recordatorios el mismo día.
|
||||
*/
|
||||
|
||||
// Labels reales del tablero Leads: NUEVO, CONTACTADO, AGENDA CITA, CALIFICADO, DESCARTADO.
|
||||
// Solo DESCARTADO es terminal (los demás siguen en seguimiento).
|
||||
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO'] as const
|
||||
|
||||
const CLAVE_RR_INDICE = 'rr:indice'
|
||||
const CLAVE_RR_VENDEDORES = 'rr:vendedores'
|
||||
const CLAVE_RR_ULTIMA_PASADA = 'rr:ultimaPasada'
|
||||
const CLAVE_INACTIVIDAD_ULTIMO_ESCANEO = 'inactividad:ultimoEscaneo'
|
||||
|
||||
const DIA_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/** Store clave-valor inyectable (Prisma en prod, Map en tests) */
|
||||
export interface StateStore {
|
||||
get(clave: string): Promise<string | null>
|
||||
set(clave: string, valor: string): Promise<void>
|
||||
}
|
||||
|
||||
// ---------- Lógica pura (testeable sin red ni DB) ----------
|
||||
|
||||
/** Parsea MONDAY_RR_USER_IDS ("106735975,106735976") a la lista de vendedores */
|
||||
export function parseRrUserIds(raw: string): UsuarioMonday[] {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '')
|
||||
.map((s) => {
|
||||
const id = Number(s)
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
throw new AppError(500, `MONDAY_RR_USER_IDS contiene un ID inválido: "${s}"`)
|
||||
}
|
||||
return { id, nombre: `Usuario ${id}`, email: '' }
|
||||
})
|
||||
}
|
||||
|
||||
export function esEstadoTerminal(estado: string, terminales: readonly string[] = ESTADOS_TERMINALES_LEAD): boolean {
|
||||
const normalizado = estado.trim().toUpperCase()
|
||||
return terminales.some((t) => t.toUpperCase() === normalizado)
|
||||
}
|
||||
|
||||
/** Vendedor al que le toca el turno `indice` (rotación circular) */
|
||||
export function vendedorEnTurno(vendedores: readonly UsuarioMonday[], indice: number): UsuarioMonday | undefined {
|
||||
if (vendedores.length === 0) return undefined
|
||||
return vendedores[((indice % vendedores.length) + vendedores.length) % vendedores.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Leads activos (estado no terminal) cuya última actualización supera
|
||||
* `diasInactividad` días respecto a `ahora`.
|
||||
*/
|
||||
export function leadsInactivos(leads: readonly LeadMonday[], ahora: Date, diasInactividad: number): LeadMonday[] {
|
||||
const limiteMs = diasInactividad * DIA_MS
|
||||
return leads.filter((lead) => {
|
||||
if (esEstadoTerminal(lead.estado)) return false
|
||||
const t = Date.parse(lead.updatedAt)
|
||||
if (Number.isNaN(t)) return false
|
||||
return ahora.getTime() - t > limiteMs
|
||||
})
|
||||
}
|
||||
|
||||
/** Clave de dedup: un recordatorio por lead por día */
|
||||
export function claveRecordatorio(itemId: string, fecha: Date): string {
|
||||
return `recordatorio:${itemId}:${fecha.toISOString().slice(0, 10)}`
|
||||
}
|
||||
|
||||
export function diasSinActividad(lead: LeadMonday, ahora: Date): number {
|
||||
return Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / DIA_MS)
|
||||
}
|
||||
|
||||
// ---------- Servicio ----------
|
||||
|
||||
type MondayLeads = Pick<
|
||||
MondayService,
|
||||
'getLeads' | 'asignarVendedorALead' | 'crearUpdateEnItem' | 'getUsuariosWorkspace'
|
||||
>
|
||||
|
||||
export interface AsignacionRR {
|
||||
itemId: string
|
||||
lead: string
|
||||
vendedorId: number
|
||||
vendedor: string
|
||||
}
|
||||
|
||||
export interface ResultadoRoundRobin {
|
||||
asignados: number
|
||||
detalle: AsignacionRR[]
|
||||
}
|
||||
|
||||
export interface CandidatoInactividad {
|
||||
itemId: string
|
||||
lead: string
|
||||
estado: string
|
||||
updatedAt: string
|
||||
diasInactivo: number
|
||||
yaRecordadoHoy: boolean
|
||||
}
|
||||
|
||||
export interface ResultadoInactividad {
|
||||
preview: boolean
|
||||
diasUmbral: number
|
||||
candidatos: CandidatoInactividad[]
|
||||
recordatoriosEnviados: number
|
||||
}
|
||||
|
||||
export class AutomatizacionesService {
|
||||
constructor(
|
||||
private readonly monday: MondayLeads,
|
||||
private readonly state: StateStore,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Lista rotativa de vendedores: env MONDAY_RR_USER_IDS si está definida;
|
||||
* si no, usuarios no-guest del workspace (cacheados en la tabla de estado).
|
||||
*/
|
||||
async obtenerVendedores(): Promise<UsuarioMonday[]> {
|
||||
if (config.automatizaciones.rrUserIds.trim() !== '') {
|
||||
return parseRrUserIds(config.automatizaciones.rrUserIds)
|
||||
}
|
||||
const cacheado = await this.state.get(CLAVE_RR_VENDEDORES)
|
||||
if (cacheado) return JSON.parse(cacheado) as UsuarioMonday[]
|
||||
const usuarios = await this.monday.getUsuariosWorkspace()
|
||||
if (usuarios.length === 0) {
|
||||
throw new AppError(502, 'Monday no devolvió usuarios asignables del workspace')
|
||||
}
|
||||
await this.state.set(CLAVE_RR_VENDEDORES, JSON.stringify(usuarios))
|
||||
return usuarios
|
||||
}
|
||||
|
||||
/**
|
||||
* Una pasada de round-robin: asigna vendedor a todo lead sin persona asignada
|
||||
* y con estado no terminal. Si se pasa `soloItemId`, solo se evalúa ese item
|
||||
* (ruta del webhook create_item).
|
||||
*/
|
||||
async runRoundRobin(soloItemId?: string): Promise<ResultadoRoundRobin> {
|
||||
const leads = await this.monday.getLeads()
|
||||
const pendientes = leads.filter(
|
||||
(l) =>
|
||||
l.vendedorIds.length === 0 &&
|
||||
!esEstadoTerminal(l.estado) &&
|
||||
(soloItemId === undefined || l.id === soloItemId),
|
||||
)
|
||||
if (pendientes.length === 0) {
|
||||
await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString())
|
||||
return { asignados: 0, detalle: [] }
|
||||
}
|
||||
|
||||
const vendedores = await this.obtenerVendedores()
|
||||
let indice = Number((await this.state.get(CLAVE_RR_INDICE)) ?? '0')
|
||||
if (!Number.isInteger(indice) || indice < 0) indice = 0
|
||||
|
||||
const detalle: AsignacionRR[] = []
|
||||
for (const lead of pendientes) {
|
||||
const vendedor = vendedorEnTurno(vendedores, indice)
|
||||
if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía')
|
||||
await this.monday.asignarVendedorALead(lead.id, vendedor.id)
|
||||
detalle.push({ itemId: lead.id, lead: lead.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre })
|
||||
indice++
|
||||
}
|
||||
|
||||
await this.state.set(CLAVE_RR_INDICE, String(indice % vendedores.length))
|
||||
await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString())
|
||||
return { asignados: detalle.length, detalle }
|
||||
}
|
||||
|
||||
/**
|
||||
* Escaneo de inactividad. Con `preview: true` no escribe nada en Monday ni en
|
||||
* el estado (sirve para el endpoint de preview).
|
||||
*/
|
||||
async escaneoInactividad(opciones: { preview: boolean; ahora?: Date }): Promise<ResultadoInactividad> {
|
||||
const ahora = opciones.ahora ?? new Date()
|
||||
const diasUmbral = config.automatizaciones.inactividadDias
|
||||
const leads = await this.monday.getLeads()
|
||||
const inactivos = leadsInactivos(leads, ahora, diasUmbral)
|
||||
|
||||
const candidatos: CandidatoInactividad[] = []
|
||||
let recordatoriosEnviados = 0
|
||||
for (const lead of inactivos) {
|
||||
const clave = claveRecordatorio(lead.id, ahora)
|
||||
const yaEnviado = (await this.state.get(clave)) !== null
|
||||
const dias = diasSinActividad(lead, ahora)
|
||||
candidatos.push({
|
||||
itemId: lead.id,
|
||||
lead: lead.nombre,
|
||||
estado: lead.estado,
|
||||
updatedAt: lead.updatedAt,
|
||||
diasInactivo: dias,
|
||||
yaRecordadoHoy: yaEnviado,
|
||||
})
|
||||
if (opciones.preview || yaEnviado) continue
|
||||
const body =
|
||||
`⚠️ Recordatorio automático: este lead lleva ${dias} días sin actualizarse ` +
|
||||
`(última actividad: ${lead.updatedAt.slice(0, 10)}). ` +
|
||||
`Favor de darle seguimiento o marcarlo como DESCARTADO.`
|
||||
await this.monday.crearUpdateEnItem(lead.id, body)
|
||||
await this.state.set(clave, ahora.toISOString())
|
||||
recordatoriosEnviados++
|
||||
}
|
||||
|
||||
if (!opciones.preview) await this.state.set(CLAVE_INACTIVIDAD_ULTIMO_ESCANEO, ahora.toISOString())
|
||||
return { preview: opciones.preview, diasUmbral, candidatos, recordatoriosEnviados }
|
||||
}
|
||||
|
||||
/** Estado actual para el endpoint admin */
|
||||
async estado(): Promise<Record<string, unknown>> {
|
||||
const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES)
|
||||
return {
|
||||
roundRobin: {
|
||||
indiceActual: Number((await this.state.get(CLAVE_RR_INDICE)) ?? '0'),
|
||||
vendedoresDeEnv: config.automatizaciones.rrUserIds.trim() !== '',
|
||||
vendedores: config.automatizaciones.rrUserIds.trim() !== ''
|
||||
? parseRrUserIds(config.automatizaciones.rrUserIds)
|
||||
: vendedoresCache
|
||||
? (JSON.parse(vendedoresCache) as UsuarioMonday[])
|
||||
: [],
|
||||
ultimaPasada: await this.state.get(CLAVE_RR_ULTIMA_PASADA),
|
||||
},
|
||||
inactividad: {
|
||||
diasUmbral: config.automatizaciones.inactividadDias,
|
||||
ultimoEscaneo: await this.state.get(CLAVE_INACTIVIDAD_ULTIMO_ESCANEO),
|
||||
},
|
||||
crons: {
|
||||
habilitados: config.automatizaciones.enabled,
|
||||
pollCron: config.automatizaciones.pollCron,
|
||||
recordatorioCron: config.automatizaciones.recordatorioCron,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Store respaldado por la tabla AutomatizacionState de Prisma */
|
||||
export const prismaStateStore: StateStore = {
|
||||
async get(clave) {
|
||||
const row = await prisma.automatizacionState.findUnique({ where: { clave } })
|
||||
return row?.valor ?? null
|
||||
},
|
||||
async set(clave, valor) {
|
||||
await prisma.automatizacionState.upsert({ where: { clave }, update: { valor }, create: { clave, valor } })
|
||||
},
|
||||
}
|
||||
|
||||
/** Singleton usado por controllers y crons (los tests instancian el suyo) */
|
||||
export const automatizacionesService = new AutomatizacionesService(mondayService, prismaStateStore)
|
||||
@@ -23,6 +23,27 @@ interface ItemMonday {
|
||||
column_values: ColumnValue[]
|
||||
}
|
||||
|
||||
interface ItemConFechas extends ItemMonday {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** Lead del tablero Leads tal como lo consumen las automatizaciones */
|
||||
export interface LeadMonday {
|
||||
id: string
|
||||
nombre: string
|
||||
updatedAt: string // ISO (updated_at del item en Monday)
|
||||
estado: string // label de la columna status 'Estado' ('' si no tiene)
|
||||
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
|
||||
}
|
||||
|
||||
/** Usuario del workspace (para la lista rotativa de vendedores) */
|
||||
export interface UsuarioMonday {
|
||||
id: number
|
||||
nombre: string
|
||||
email: string
|
||||
}
|
||||
|
||||
interface GraphQLErrorItem {
|
||||
message: string
|
||||
extensions?: { code?: string }
|
||||
@@ -350,6 +371,114 @@ export class MondayService {
|
||||
)
|
||||
this.invalidarCacheUnidades()
|
||||
}
|
||||
|
||||
// ---------- Leads (automatizaciones) ----------
|
||||
|
||||
/** Items del tablero Leads con estado, fecha de actualización y vendedor asignado */
|
||||
async getLeads(): Promise<LeadMonday[]> {
|
||||
const boardId = config.monday.boards.leads
|
||||
const cols = await this.columnasDe(boardId)
|
||||
const colVendedor = cols.get('Vendedor')
|
||||
if (!colVendedor || colVendedor.type !== 'people') {
|
||||
throw new AppError(500, `Columna people "Vendedor" no existe en el tablero Leads (${boardId})`)
|
||||
}
|
||||
const colEstado = cols.get('Estado')
|
||||
|
||||
const items: ItemConFechas[] = []
|
||||
let cursor: string | null = null
|
||||
do {
|
||||
const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } =
|
||||
cursor === null
|
||||
? await this.graphql(
|
||||
`query ($ids: [ID!]) {
|
||||
boards(ids: $ids) {
|
||||
items_page(limit: 200) {
|
||||
cursor
|
||||
items { id name created_at updated_at column_values { id text value } }
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ ids: [boardId] },
|
||||
)
|
||||
: await this.graphql(
|
||||
`query ($ids: [ID!], $cursor: String!) {
|
||||
boards(ids: $ids) {
|
||||
items_page(limit: 200, cursor: $cursor) {
|
||||
cursor
|
||||
items { id name created_at updated_at column_values { id text value } }
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ ids: [boardId], cursor },
|
||||
)
|
||||
const page = data.boards[0]?.items_page
|
||||
if (!page) break
|
||||
items.push(...page.items)
|
||||
cursor = page.cursor
|
||||
} while (cursor !== null)
|
||||
|
||||
return items.map((item) => {
|
||||
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
||||
const rawVendedor = porId.get(colVendedor.id)?.value
|
||||
let vendedorIds: number[] = []
|
||||
if (rawVendedor) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawVendedor) as { personsAndTeams?: { id: number; kind: string }[] }
|
||||
vendedorIds = (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id)
|
||||
} catch {
|
||||
vendedorIds = []
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: item.id,
|
||||
nombre: item.name,
|
||||
updatedAt: item.updated_at,
|
||||
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
|
||||
vendedorIds,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
|
||||
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
|
||||
const boardId = config.monday.boards.leads
|
||||
const col = await this.columnaId(boardId, 'Vendedor')
|
||||
await this.graphql(
|
||||
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
|
||||
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
|
||||
}`,
|
||||
{
|
||||
boardId,
|
||||
itemId,
|
||||
columnId: col.id,
|
||||
value: JSON.stringify({ personsAndTeams: [{ id: userId, kind: 'person' }] }),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Crea un update (comentario) en un item — usado para los recordatorios de inactividad */
|
||||
async crearUpdateEnItem(itemId: string, body: string): Promise<string> {
|
||||
const data = await this.graphql<{ create_update: { id: string } }>(
|
||||
`mutation ($itemId: ID!, $body: String!) {
|
||||
create_update(item_id: $itemId, body: $body) { id }
|
||||
}`,
|
||||
{ itemId, body },
|
||||
)
|
||||
return data.create_update.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuarios activos no-guest del workspace (excluye las cuentas bot de Monday,
|
||||
* cuyo email termina en @agent.monday.com).
|
||||
*/
|
||||
async getUsuariosWorkspace(): Promise<UsuarioMonday[]> {
|
||||
const data = await this.graphql<{
|
||||
users: { id: string; name: string; email: string; is_guest: boolean; enabled: boolean }[]
|
||||
}>(`query { users(limit: 50) { id name email is_guest enabled } }`)
|
||||
return data.users
|
||||
.filter((u) => !u.is_guest && u.enabled && !u.email.endsWith('@agent.monday.com'))
|
||||
.map((u) => ({ id: Number(u.id), nombre: u.name, email: u.email }))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- DTOs de escritura ----------
|
||||
|
||||
Reference in New Issue
Block a user