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:
2026-08-22 02:37:16 +00:00
parent 05f6ceb01a
commit 8407fc24c3
13 changed files with 781 additions and 12 deletions

View File

@@ -14,6 +14,17 @@ MONDAY_BOARD_OPORTUNIDADES="18427678497"
MONDAY_BOARD_APARTADO="18427678515" MONDAY_BOARD_APARTADO="18427678515"
MONDAY_BOARD_VENTAS="18427678520" MONDAY_BOARD_VENTAS="18427678520"
# Automatizaciones del tablero Leads (api-gateway)
# IDs de usuario Monday separados por coma para el round-robin;
# vacío = auto-detectar usuarios no-guest del workspace
MONDAY_RR_USER_IDS=""
# Días sin actividad para recordar un lead (update automático en el item)
INACTIVIDAD_DIAS="10"
# Cron de polling de respaldo (asigna vendedor a leads nuevos sin asignar)
POLL_CRON="*/5 * * * *"
# 'false' desactiva los crons (los endpoints /api/automatizaciones siguen vivos)
MONDAY_AUTOMATIONS_ENABLED="true"
# ============================================ # ============================================
# BASE DE DATOS (fase API Gateway) # BASE DE DATOS (fase API Gateway)
# ============================================ # ============================================

View File

@@ -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/abonos` | Aplica abono (estricto al centavo) |
| POST | `/api/planes/:id/notas` | Agrega nota a la bitácora | | POST | `/api/planes/:id/notas` | Agrega nota a la bitácora |
| POST | `/api/planes/:id/cancelar` | Cancela el plan | | 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 ## 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. - **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. - **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. - **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) ## TODO producción (pendientes deliberados)

View File

@@ -13,6 +13,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^5.1.0", "express": "^5.1.0",
"multer": "^2.0.2", "multer": "^2.0.2",
"node-cron": "^4.6.0",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
@@ -20,6 +21,7 @@
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^22.15.0", "@types/node": "^22.15.0",
"@types/node-cron": "^3.0.11",
"prisma": "^6.14.0", "prisma": "^6.14.0",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^5.9.2", "typescript": "^5.9.2",
@@ -1045,6 +1047,13 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/qs": {
"version": "6.15.1", "version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@@ -2244,6 +2253,15 @@
"url": "https://opencollective.com/express" "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": { "node_modules/node-fetch-native": {
"version": "1.6.7", "version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",

View File

@@ -20,6 +20,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^5.1.0", "express": "^5.1.0",
"multer": "^2.0.2", "multer": "^2.0.2",
"node-cron": "^4.6.0",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
@@ -27,6 +28,7 @@
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^22.15.0", "@types/node": "^22.15.0",
"@types/node-cron": "^3.0.11",
"prisma": "^6.14.0", "prisma": "^6.14.0",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^5.9.2", "typescript": "^5.9.2",

View File

@@ -112,6 +112,14 @@ model AplicacionPago {
@@index([pagoId]) @@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 { model NotaPlan {
id String @id @default(uuid()) id String @id @default(uuid())
planId String planId String

View File

@@ -29,6 +29,15 @@ export const config = {
ventas: env('MONDAY_BOARD_VENTAS'), 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 } as const
export type AppConfig = typeof config export type AppConfig = typeof config

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

View File

@@ -1,22 +1,27 @@
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import { z } from 'zod' import { z } from 'zod'
import { config } from '../config/index.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
const eventoSchema = z const eventoSchema = z
.object({ .object({
type: z.string().optional(), type: z.string(),
boardId: z.union([z.string(), z.number()]).optional(), boardId: z.union([z.string(), z.number()]),
itemId: z.union([z.string(), z.number()]).optional(), itemId: z.union([z.string(), z.number()]).optional(),
}) })
.passthrough() .passthrough()
const payloadSchema = z.object({ event: eventoSchema }).passthrough()
/** /**
* POST /api/webhooks/monday * POST /api/webhooks/monday
* Handshake: Monday envía { challenge } al registrar el webhook y exige que * 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 * OJO: Monday NO firma sus webhooks → el payload no es confiable. Por eso el
* actuar sobre un evento hay que re-consultar el estado vía API (por ahora * evento solo se usa como DISPARADOR: el round-robin re-consulta el tablero
* solo se registra el evento). * vía API antes de escribir nada.
*/ */
export function webhookMonday(req: Request, res: Response): void { export function webhookMonday(req: Request, res: Response): void {
const body = req.body as Record<string, unknown> const body = req.body as Record<string, unknown>
@@ -26,11 +31,34 @@ export function webhookMonday(req: Request, res: Response): void {
return return
} }
const parsed = eventoSchema.safeParse(body) const parsed = payloadSchema.safeParse(body)
if (parsed.success) { if (!parsed.success) {
const { type, boardId, itemId } = parsed.data
// eslint-disable-next-line no-console // 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) res.sendStatus(200)
} }

View File

@@ -1,8 +1,41 @@
import cron from 'node-cron'
import { crearApp } from './app.js' import { crearApp } from './app.js'
import { config } from './config/index.js' import { config } from './config/index.js'
import { automatizacionesService } from './services/automatizaciones.service.js'
const app = crearApp() 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, () => { app.listen(config.port, () => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`API Gateway FRASA escuchando en http://localhost:${config.port} (${config.nodeEnv})`) console.log(`API Gateway FRASA escuchando en http://localhost:${config.port} (${config.nodeEnv})`)

View File

@@ -23,6 +23,11 @@ import {
registrarAbonoEndpoint, registrarAbonoEndpoint,
} from '../controllers/planes.controller.js' } from '../controllers/planes.controller.js'
import { webhookMonday } from '../controllers/webhooks.controller.js' import { webhookMonday } from '../controllers/webhooks.controller.js'
import {
correrRoundRobin,
estadoAutomatizaciones,
previewInactividad,
} from '../controllers/automatizaciones.controller.js'
export const apiRouter = Router() export const apiRouter = Router()
@@ -49,3 +54,7 @@ apiRouter.post('/planes/:id/notas', agregarNota)
apiRouter.post('/planes/:id/cancelar', cancelarPlan) apiRouter.post('/planes/:id/cancelar', cancelarPlan)
apiRouter.post('/webhooks/monday', webhookMonday) apiRouter.post('/webhooks/monday', webhookMonday)
apiRouter.get('/automatizaciones/estado', estadoAutomatizaciones)
apiRouter.post('/automatizaciones/round-robin/run', correrRoundRobin)
apiRouter.get('/automatizaciones/inactividad/preview', previewInactividad)

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

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

View File

@@ -23,6 +23,27 @@ interface ItemMonday {
column_values: ColumnValue[] 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 { interface GraphQLErrorItem {
message: string message: string
extensions?: { code?: string } extensions?: { code?: string }
@@ -350,6 +371,114 @@ export class MondayService {
) )
this.invalidarCacheUnidades() 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 ---------- // ---------- DTOs de escritura ----------