From 8407fc24c31ab1237c75d296b49a39b5a3ad35b0 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Sat, 22 Aug 2026 02:37:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(api):=20automatizaciones=20Monday=20?= =?UTF-8?q?=E2=80=94=20round-robin=20de=20leads,=20recordatorio=20inactivi?= =?UTF-8?q?dad=2010d,=20webhooks=20reales=20y=20polling=20cron?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .env.example | 11 + api-gateway/README.md | 13 +- api-gateway/package-lock.json | 18 ++ api-gateway/package.json | 2 + api-gateway/prisma/schema.prisma | 8 + api-gateway/src/config/index.ts | 9 + .../automatizaciones.controller.ts | 17 ++ .../src/controllers/webhooks.controller.ts | 48 +++- api-gateway/src/index.ts | 33 +++ api-gateway/src/routes/index.ts | 9 + .../services/automatizaciones.service.test.ts | 240 ++++++++++++++++ .../src/services/automatizaciones.service.ts | 256 ++++++++++++++++++ api-gateway/src/services/monday.service.ts | 129 +++++++++ 13 files changed, 781 insertions(+), 12 deletions(-) create mode 100644 api-gateway/src/controllers/automatizaciones.controller.ts create mode 100644 api-gateway/src/services/automatizaciones.service.test.ts create mode 100644 api-gateway/src/services/automatizaciones.service.ts diff --git a/.env.example b/.env.example index 953d7ca..e01846e 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,17 @@ MONDAY_BOARD_OPORTUNIDADES="18427678497" MONDAY_BOARD_APARTADO="18427678515" 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) # ============================================ diff --git a/api-gateway/README.md b/api-gateway/README.md index 1466462..5284e9b 100644 --- a/api-gateway/README.md +++ b/api-gateway/README.md @@ -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::` 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) diff --git a/api-gateway/package-lock.json b/api-gateway/package-lock.json index 84eabac..ab0bf94 100644 --- a/api-gateway/package-lock.json +++ b/api-gateway/package-lock.json @@ -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", diff --git a/api-gateway/package.json b/api-gateway/package.json index 4be7668..e1fcaed 100644 --- a/api-gateway/package.json +++ b/api-gateway/package.json @@ -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", diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 439ba09..1ad4acf 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -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::', etc. +model AutomatizacionState { + clave String @id + valor String + updatedAt DateTime @updatedAt +} + model NotaPlan { id String @id @default(uuid()) planId String diff --git a/api-gateway/src/config/index.ts b/api-gateway/src/config/index.ts index 03fdad1..cc0a64c 100644 --- a/api-gateway/src/config/index.ts +++ b/api-gateway/src/config/index.ts @@ -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 diff --git a/api-gateway/src/controllers/automatizaciones.controller.ts b/api-gateway/src/controllers/automatizaciones.controller.ts new file mode 100644 index 0000000..0d1943f --- /dev/null +++ b/api-gateway/src/controllers/automatizaciones.controller.ts @@ -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 { + 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 { + 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 { + res.json(await automatizacionesService.escaneoInactividad({ preview: true })) +} diff --git a/api-gateway/src/controllers/webhooks.controller.ts b/api-gateway/src/controllers/webhooks.controller.ts index f59ae46..6bd2b13 100644 --- a/api-gateway/src/controllers/webhooks.controller.ts +++ b/api-gateway/src/controllers/webhooks.controller.ts @@ -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 @@ -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) } diff --git a/api-gateway/src/index.ts b/api-gateway/src/index.ts index 93a9b8d..b5dc3e7 100644 --- a/api-gateway/src/index.ts +++ b/api-gateway/src/index.ts @@ -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})`) diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 1da8e65..9ec33da 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -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) diff --git a/api-gateway/src/services/automatizaciones.service.test.ts b/api-gateway/src/services/automatizaciones.service.test.ts new file mode 100644 index 0000000..c57ff3d --- /dev/null +++ b/api-gateway/src/services/automatizaciones.service.test.ts @@ -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 { + 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 } { + const mapa = new Map() + 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, 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) + }) +}) diff --git a/api-gateway/src/services/automatizaciones.service.ts b/api-gateway/src/services/automatizaciones.service.ts new file mode 100644 index 0000000..8bac6aa --- /dev/null +++ b/api-gateway/src/services/automatizaciones.service.ts @@ -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 + set(clave: string, valor: string): Promise +} + +// ---------- 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 { + 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 { + 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 { + 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> { + 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) diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index d1030f8..5e8af81 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 ----------