feat(api): webhooks Monday en tiempo real + alertas de inventario cableadas

- 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
This commit is contained in:
2026-08-28 16:46:08 +00:00
parent 30c682e4a0
commit 04941e9f80
10 changed files with 313 additions and 29 deletions

View File

@@ -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<typeof vi.fn>; sendStatus: ReturnType<typeof vi.fn> } {
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))
})
})

View File

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