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

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