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 0000000..bab483d Binary files /dev/null and b/api-gateway/scripts/__pycache__/sync-estatus-maestro.cpython-312.pyc differ diff --git a/api-gateway/scripts/sync-estatus-maestro.py b/api-gateway/scripts/sync-estatus-maestro.py index 3b8b9f8..91a06c8 100644 --- a/api-gateway/scripts/sync-estatus-maestro.py +++ b/api-gateway/scripts/sync-estatus-maestro.py @@ -115,6 +115,42 @@ def main(): print(f'{aplicados} items actualizados en Monday') for code, motivo in omitidos: print(f' omitido {code}: {motivo}') + if not DRY and aplicados: + notificar_alertas(cambios) + + +def notificar_alertas(cambios): + """Avisa al API local de los cambios aplicados (alertas de inventario al + vendedor con cotizaciones recientes de la unidad). Best-effort: un fallo + aquí no revierte lo ya escrito en Monday.""" + token = os.environ.get('INTERNAL_SYNC_TOKEN') or lee_env('INTERNAL_SYNC_TOKEN') + if not token: + print(' (sin INTERNAL_SYNC_TOKEN: se omiten las alertas de inventario)') + return + payload = json.dumps({'cambios': [ + {'unidadCodigo': c['codigo'], 'statusNuevo': c['a']} for c in cambios + ]}).encode() + req = urllib.request.Request( + 'http://localhost:3000/api/cotizaciones/alertas-inventario', + data=payload, + headers={'Content-Type': 'application/json', 'X-Internal-Token': token}) + try: + res = json.load(urllib.request.urlopen(req, timeout=15)) + print(f" alertas-inventario: {res.get('alertasCreadas', 0)} actividad(es) creada(s), " + f"{res.get('omitidas', 0)} omitida(s), {res.get('fallidas', 0)} fallida(s)") + except Exception as e: + print(f' AVISO: no se pudieron enviar las alertas de inventario: {e}') + + +def lee_env(nombre): + env = os.path.join(ROOT, '../../.env') + try: + for line in open(env): + if line.startswith(nombre + '='): + return line.strip().split('=', 1)[1].strip().strip('"').strip("'") + except OSError: + pass + return None if __name__ == '__main__': diff --git a/api-gateway/src/config/index.ts b/api-gateway/src/config/index.ts index 9bde5f1..29b3d83 100644 --- a/api-gateway/src/config/index.ts +++ b/api-gateway/src/config/index.ts @@ -15,6 +15,10 @@ export const config = { nodeEnv: env('NODE_ENV', 'development'), databaseUrl: env('DATABASE_URL', 'file:./dev.db'), uploadsDir: path.resolve(process.cwd(), env('UPLOADS_DIR', './uploads')), + // Token compartido para llamadas internas script→API corriendo en este mismo + // host (sync de estatus → POST /api/cotizaciones/alertas-inventario). Vacío = + // desactivado: el endpoint exige JWT de usuario como siempre. + internalSyncToken: env('INTERNAL_SYNC_TOKEN', ''), monday: { apiKey: env('MONDAY_API_KEY'), apiUrl: env('MONDAY_API_URL', 'https://api.monday.com/v2'), diff --git a/api-gateway/src/controllers/webhooks.controller.test.ts b/api-gateway/src/controllers/webhooks.controller.test.ts new file mode 100644 index 0000000..b9812ac --- /dev/null +++ b/api-gateway/src/controllers/webhooks.controller.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' + +const mocks = vi.hoisted(() => ({ + 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.