From 04941e9f80d5d3ca3ca72b91b3bb66b5e6b676f3 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 28 Aug 2026 16:46:08 +0000 Subject: [PATCH] feat(api): webhooks Monday en tiempo real + alertas de inventario cableadas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Webhook Oportunidades: cambio de etapa dispara escaneo (Aparto→Apartado, Ganado→Venta) además del round-robin de leads por create_item - Acepta los nombres reales de los eventos de Monday: create_pulse/pulseId y update_column_value (verificados en vivo) - Token interno (X-Internal-Token) para que los syncs locales llamen POST /cotizaciones/alertas-inventario sin JWT de usuario - sync-estatus-maestro.py notifica los cambios aplicados al endpoint de alertas (actividad al vendedor con cotizaciones recientes) - Fix P1008 SQLite: escaneos del poll cron secuenciales + connection_limit=1 en DATABASE_URL - Webhooks registrados en Monday sobre https://frasa.consultoria-as.com (Leads create_item, Oportunidades change_column_value); crons quedan como red de seguridad - Docs: sección 0 en AUTOMATIZACIONES-MONDAY.md con la matriz de eventos y los quirks de Monday --- .env.example | 3 + .../sync-estatus-maestro.cpython-312.pyc | Bin 0 -> 10162 bytes api-gateway/scripts/sync-estatus-maestro.py | 36 ++++++ api-gateway/src/config/index.ts | 4 + .../controllers/webhooks.controller.test.ts | 112 ++++++++++++++++++ .../src/controllers/webhooks.controller.ts | 42 ++++++- api-gateway/src/index.ts | 40 ++++--- .../src/middleware/auth.middleware.test.ts | 39 ++++++ api-gateway/src/middleware/auth.middleware.ts | 14 +++ docs/AUTOMATIZACIONES-MONDAY.md | 52 ++++++-- 10 files changed, 313 insertions(+), 29 deletions(-) create mode 100644 api-gateway/scripts/__pycache__/sync-estatus-maestro.cpython-312.pyc create mode 100644 api-gateway/src/controllers/webhooks.controller.test.ts diff --git a/.env.example b/.env.example index 1e963bc..4d37dcd 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,9 @@ JWT_EXPIRES_IN="7d" AUTH_ENABLED=true # Password inicial del usuario 'admin' sembrado por npm run seed:usuarios ADMIN_PASSWORD="Frasa2026" +# Token compartido script→API en este host (sync de estatus → alertas de +# inventario). Generar con: openssl rand -hex 24 +INTERNAL_SYNC_TOKEN="" # ============================================ # APP CONFIG diff --git a/api-gateway/scripts/__pycache__/sync-estatus-maestro.cpython-312.pyc b/api-gateway/scripts/__pycache__/sync-estatus-maestro.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bab483d7b1981447212d1563e4eabd5dfbbd76a7 GIT binary patch literal 10162 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 ({ + runRoundRobin: vi.fn(), + escaneoOportunidades: vi.fn(), +})) + +vi.mock('../services/automatizaciones.service.js', () => ({ + automatizacionesService: { + runRoundRobin: mocks.runRoundRobin, + escaneoOportunidades: mocks.escaneoOportunidades, + }, +})) + +vi.mock('../config/index.js', () => ({ + config: { + monday: { + boards: { leads: '111', oportunidades: '222' }, + }, + }, +})) + +import { webhookMonday } from './webhooks.controller.js' + +function resFake(): { res: Response; json: ReturnType; sendStatus: ReturnType } { + const json = vi.fn() + const sendStatus = vi.fn() + return { res: { json, sendStatus } as unknown as Response, json, sendStatus } +} + +const reqCon = (body: unknown): Request => ({ body } as Request) + +beforeEach(() => { + vi.clearAllMocks() + mocks.runRoundRobin.mockResolvedValue({ asignados: 1 }) + mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] }) +}) + +describe('webhookMonday', () => { + it('responde el challenge del handshake tal cual', () => { + const { res, json, sendStatus } = resFake() + webhookMonday(reqCon({ challenge: 'abc-123' }), res) + expect(json).toHaveBeenCalledWith({ challenge: 'abc-123' }) + expect(sendStatus).not.toHaveBeenCalled() + }) + + it('ignora payloads sin estructura de evento (200 sin disparar nada)', () => { + const { res, sendStatus } = resFake() + webhookMonday(reqCon({ foo: 'bar' }), res) + expect(sendStatus).toHaveBeenCalledWith(200) + expect(mocks.runRoundRobin).not.toHaveBeenCalled() + expect(mocks.escaneoOportunidades).not.toHaveBeenCalled() + }) + + it('create_item en Leads dispara el round-robin del item', () => { + const { res, sendStatus } = resFake() + webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 999 } }), res) + expect(sendStatus).toHaveBeenCalledWith(200) + expect(mocks.runRoundRobin).toHaveBeenCalledWith('999') + }) + + it('create_pulse (nombre real del evento) con pulseId también dispara el round-robin', () => { + const { res, sendStatus } = resFake() + webhookMonday(reqCon({ event: { type: 'create_pulse', boardId: 111, pulseId: 777 } }), res) + expect(sendStatus).toHaveBeenCalledWith(200) + expect(mocks.runRoundRobin).toHaveBeenCalledWith('777') + }) + + it('create_item en otro board no dispara round-robin', () => { + const { res, sendStatus } = resFake() + webhookMonday(reqCon({ event: { type: 'create_item', boardId: 555, itemId: 999 } }), res) + expect(sendStatus).toHaveBeenCalledWith(200) + expect(mocks.runRoundRobin).not.toHaveBeenCalled() + }) + + it('change_status_column_value en Oportunidades dispara el escaneo', () => { + const { res, sendStatus } = resFake() + webhookMonday(reqCon({ event: { type: 'change_status_column_value', boardId: '222', itemId: 1 } }), res) + expect(sendStatus).toHaveBeenCalledWith(200) + expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce() + }) + + it('change_column_value en Oportunidades también dispara el escaneo', () => { + const { res } = resFake() + webhookMonday(reqCon({ event: { type: 'change_column_value', boardId: 222, itemId: 1 } }), res) + expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce() + }) + + it('update_column_value (nombre real del evento) en Oportunidades dispara el escaneo', () => { + const { res, sendStatus } = resFake() + webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 222, pulseId: 5 } }), res) + expect(sendStatus).toHaveBeenCalledWith(200) + expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce() + }) + + it('cambio de status en otro board no dispara el escaneo de oportunidades', () => { + const { res, sendStatus } = resFake() + webhookMonday(reqCon({ event: { type: 'change_status_column_value', boardId: 333, itemId: 1 } }), res) + expect(sendStatus).toHaveBeenCalledWith(200) + expect(mocks.escaneoOportunidades).not.toHaveBeenCalled() + }) + + it('un error del servicio no rompe la respuesta 200 (fire-and-forget)', async () => { + mocks.runRoundRobin.mockRejectedValue(new Error('monday caído')) + const { res, sendStatus } = resFake() + webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 1 } }), res) + expect(sendStatus).toHaveBeenCalledWith(200) + // Deja que el rechazo se procese para no ensuciar otros tests + await new Promise((r) => setImmediate(r)) + }) +}) diff --git a/api-gateway/src/controllers/webhooks.controller.ts b/api-gateway/src/controllers/webhooks.controller.ts index 6bd2b13..752ca82 100644 --- a/api-gateway/src/controllers/webhooks.controller.ts +++ b/api-gateway/src/controllers/webhooks.controller.ts @@ -7,7 +7,10 @@ const eventoSchema = z .object({ type: z.string(), boardId: z.union([z.string(), z.number()]), + // Monday manda pulseId en los eventos reales (create_pulse) y itemId en + // las pruebas del panel de webhooks; se aceptan ambos. itemId: z.union([z.string(), z.number()]).optional(), + pulseId: z.union([z.string(), z.number()]).optional(), }) .passthrough() @@ -39,14 +42,18 @@ export function webhookMonday(req: Request, res: Response): void { return } - const { type, boardId, itemId } = parsed.data.event + const { type, boardId, itemId, pulseId } = parsed.data.event const esBoardLeads = String(boardId) === config.monday.boards.leads + const esBoardOportunidades = String(boardId) === config.monday.boards.oportunidades + const idEvento = itemId ?? pulseId // eslint-disable-next-line no-console - console.log(`[webhook monday] tipo=${type} board=${boardId} item=${itemId ?? '?'}`) + console.log(`[webhook monday] tipo=${type} board=${boardId} item=${idEvento ?? '?'}`) - if (type === 'create_item' && esBoardLeads && itemId !== undefined) { - const id = String(itemId) + // Monday registra el webhook como 'create_item' pero el payload real llega + // como 'create_pulse'; se aceptan ambos. + if ((type === 'create_item' || type === 'create_pulse') && esBoardLeads && idEvento !== undefined) { + const id = String(idEvento) // Fire-and-forget: la respuesta 200 no espera a que termine la asignación void automatizacionesService .runRoundRobin(id) @@ -60,5 +67,32 @@ export function webhookMonday(req: Request, res: Response): void { }) } + // Cambio de etapa en Oportunidades → crear Apartado/Venta si aplica. + // El payload de Monday no es confiable (no va firmado), así que solo se usa + // como disparador: el escaneo re-consulta el tablero y es idempotente (dedup + // en AutomatizacionState). El cron de polling queda como red de seguridad. + // OJO: el webhook se registra como 'change_column_value' pero el payload + // real llega como 'update_column_value'; se aceptan los tres nombres. + if ( + (type === 'change_status_column_value' || + type === 'change_column_value' || + type === 'update_column_value') && + esBoardOportunidades + ) { + void automatizacionesService + .escaneoOportunidades() + .then((r) => { + // eslint-disable-next-line no-console + console.log( + `[webhook monday] oportunidades: ${r.apartadosCreados.length} apartado(s), ` + + `${r.ventasCreadas.length} venta(s), ${r.errores.length} error(es)`, + ) + }) + .catch((err: unknown) => { + // eslint-disable-next-line no-console + console.error('[webhook monday] error en escaneo de oportunidades:', err) + }) + } + res.sendStatus(200) } diff --git a/api-gateway/src/index.ts b/api-gateway/src/index.ts index 5ecb61d..899f7b4 100644 --- a/api-gateway/src/index.ts +++ b/api-gateway/src/index.ts @@ -6,36 +6,40 @@ import { recordatoriosCobranzaService } from './services/recordatorios-cobranza. 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. +// Crons de respaldo: los webhooks de Monday (registrados sobre +// https://frasa.consultoria-as.com/api/webhooks/monday) disparan estas mismas +// reglas en tiempo real; estos barridos quedan como red de seguridad por si un +// evento se pierde. Se desactivan con MONDAY_AUTOMATIONS_ENABLED=false. if (config.automatizaciones.enabled) { // Polling: asigna vendedor (round-robin) a leads nuevos sin persona asignada, // escala leads sin contactar y crea Apartados/Ventas al moverse Oportunidades. // Cada regla tiene su propio catch: un fallo de Monday no rompe las demás. + // SECUENCIAL a propósito: SQLite serializa las escrituras y lanzar los tres + // escaneos en paralelo provoca timeouts P1008 en AutomatizacionState. cron.schedule(config.automatizaciones.pollCron, () => { - automatizacionesService - .runRoundRobin() - .then((r) => { + void (async () => { + try { + const r = await automatizacionesService.runRoundRobin() 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)) + } catch (err: unknown) { + console.error('[cron poll] error en round-robin:', err) + } - automatizacionesService - .escaneoEscalado() - .then((r) => { + try { + const r = await automatizacionesService.escaneoEscalado() if (r.escalados > 0) { // eslint-disable-next-line no-console console.log(`[cron poll] escalado: ${r.escalados} lead(s) escalado(s)`) } - }) - .catch((err: unknown) => console.error('[cron poll] error en escalado:', err)) + } catch (err: unknown) { + console.error('[cron poll] error en escalado:', err) + } - automatizacionesService - .escaneoOportunidades() - .then((r) => { + try { + const r = await automatizacionesService.escaneoOportunidades() if (r.apartadosCreados.length > 0 || r.ventasCreadas.length > 0 || r.errores.length > 0) { // eslint-disable-next-line no-console console.log( @@ -43,8 +47,10 @@ if (config.automatizaciones.enabled) { `${r.ventasCreadas.length} venta(s), ${r.errores.length} error(es)`, ) } - }) - .catch((err: unknown) => console.error('[cron poll] error en oportunidades:', err)) + } catch (err: unknown) { + console.error('[cron poll] error en oportunidades:', err) + } + })() }) // Escaneo diario de leads inactivos (recordatorio vía update en el item) diff --git a/api-gateway/src/middleware/auth.middleware.test.ts b/api-gateway/src/middleware/auth.middleware.test.ts index 620e8e8..e8bd9af 100644 --- a/api-gateway/src/middleware/auth.middleware.test.ts +++ b/api-gateway/src/middleware/auth.middleware.test.ts @@ -12,6 +12,7 @@ vi.mock('../config/index.js', () => ({ jwtExpiresIn: '7d', adminPassword: 'Frasa2026', }, + internalSyncToken: 'test-internal-token', }, })) @@ -129,4 +130,42 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => { authMiddleware(reqFake({ method: 'GET', path: '/webhooks/monday' }), res, next) expect(status).toHaveBeenCalledWith(401) }) + + it('el token interno abre solo POST /cotizaciones/alertas-inventario', () => { + authMiddleware( + reqFake({ + method: 'POST', + path: '/cotizaciones/alertas-inventario', + headers: { 'x-internal-token': 'test-internal-token' }, + }), + resFake().res, + next, + ) + expect(next).toHaveBeenCalledOnce() + }) + + it('token interno incorrecto o en otra ruta → 401', () => { + const casos: Partial[] = [ + // token incorrecto en el endpoint correcto + { + method: 'POST', + path: '/cotizaciones/alertas-inventario', + headers: { 'x-internal-token': 'otro-token' }, + }, + // token correcto en otra ruta + { method: 'POST', path: '/clientes', headers: { 'x-internal-token': 'test-internal-token' } }, + { method: 'GET', path: '/unidades', headers: { 'x-internal-token': 'test-internal-token' } }, + // otro método sobre el endpoint + { + method: 'GET', + path: '/cotizaciones/alertas-inventario', + headers: { 'x-internal-token': 'test-internal-token' }, + }, + ] + for (const c of casos) { + const { res, status } = resFake() + authMiddleware(reqFake(c), res, next) + expect(status).toHaveBeenCalledWith(401) + } + }) }) diff --git a/api-gateway/src/middleware/auth.middleware.ts b/api-gateway/src/middleware/auth.middleware.ts index 001dc7a..ae80a36 100644 --- a/api-gateway/src/middleware/auth.middleware.ts +++ b/api-gateway/src/middleware/auth.middleware.ts @@ -48,6 +48,20 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): return } + // Token interno script→API: solo abre el endpoint de alertas de inventario. + // Los syncs corren en este mismo host y no tienen JWT de usuario. La guarda + // exige token no vacío Y header idéntico (un header ausente nunca matchea). + if ( + req.method === 'POST' && + req.path === '/cotizaciones/alertas-inventario' && + typeof config.internalSyncToken === 'string' && + config.internalSyncToken.length > 0 && + req.headers['x-internal-token'] === config.internalSyncToken + ) { + next() + return + } + const header = req.headers.authorization const token = header?.startsWith('Bearer ') ? header.slice('Bearer '.length).trim() : null if (!token) { diff --git a/docs/AUTOMATIZACIONES-MONDAY.md b/docs/AUTOMATIZACIONES-MONDAY.md index f2dbd55..a86b84a 100644 --- a/docs/AUTOMATIZACIONES-MONDAY.md +++ b/docs/AUTOMATIZACIONES-MONDAY.md @@ -3,12 +3,48 @@ Recetas que se activan **desde la UI de Monday** (botón "Automatizar" en cada tablero). Tiempo estimado total: ~10 minutos. Ninguna requiere código. -> **No duplicar**: el servidor (API Gateway, crons cada 5 min) ya cubre la -> asignación round-robin de vendedores, el recordatorio de leads inactivos -> (10 días), el escalado de leads sin contactar (48 h), y la creación de items -> en Apartado / Ventas cuando una oportunidad entra a **Aparto** o **Ganado**. -> Las recetas de abajo son solo *avisos y orden visual*; no repitas esas reglas -> como recetas nativas. +> **No duplicar**: el servidor (API Gateway) ya cubre la asignación round-robin +> de vendedores, el recordatorio de leads inactivos (10 días), el escalado de +> leads sin contactar (48 h), y la creación de items en Apartado / Ventas +> cuando una oportunidad entra a **Aparto** o **Ganado**. Las recetas de abajo +> son solo *avisos y orden visual*; no repitas esas reglas como recetas +> nativas. + +## 0. Cómo llegan los eventos al servidor (webhooks + crons) + +Desde que el stack tiene dominio público (Cloudflare Tunnel), Monday avisa al +servidor **en tiempo real** vía webhooks registrados en +`https://frasa.consultoria-as.com/api/webhooks/monday`: + +| Board | Evento registrado | Reacción del servidor | +|---|---|---| +| Leads | `create_item` | Round-robin: asigna Vendedor al lead nuevo | +| Oportunidades | `change_column_value` | Si la etapa es **Aparto** → crea item en Apartado; si es **Ganado** → crea item en Ventas | + +Los **crons cada 5 min** (`POLL_CRON`) siguen activos como red de seguridad: +repiten los mismos escaneos (idempotentes, con dedup en la base) por si un +evento se pierde. El escaneo de **inactividad** (9:00) y los **recordatorios +de cobranza** (9:30) solo corren por cron. + +Notas técnicas (quirks de Monday, ya contemplados en el código): + +- El webhook se registra como `create_item` pero el payload real llega como + `create_pulse`, y el id viene en `pulseId` (no `itemId`). +- Los cambios de columna llegan como `update_column_value`. +- El evento `change_status_column_value` **no dispara** en estos boards; por + eso se registró `change_column_value`. +- Monday no firma sus webhooks: el servidor usa el evento solo como disparador + y re-consulta el tablero por API antes de escribir nada. + +Para re-registrar un webhook (p. ej. si cambia el dominio): + +```graphql +mutation { + create_webhook(board_id: , + url: "https://frasa.consultoria-as.com/api/webhooks/monday", + event: create_item) { id } +} +``` ## 1. Tablero Leads — avisar al vendedor cuando se le asigna un lead @@ -66,5 +102,5 @@ tablero mantiene el orden sin trabajo manual: 1. Crea un item de prueba en **Leads**, asígnate como Vendedor → debe llegarte la notificación de la receta 1. Bórralo después. 2. En **Oportunidades**, cambia un item de prueba a **Ganado** → notificación - de la receta 2 **y** (en ≤ 5 min, por el servidor) un item nuevo en - **Ventas**. Borra ambos después. + de la receta 2 **y** (en segundos, vía webhook; ≤ 5 min por el cron de + respaldo) un item nuevo en **Ventas**. Borra ambos después.