import type { Request, Response } from 'express' import { z } from 'zod' import { config } from '../config/index.js' import { automatizacionesService } from '../services/automatizaciones.service.js' import { funnelService } from '../services/funnel.service.js' import { GRUPOS_POR_ESTADO, moverAGrupoSegunEstado } from '../services/grupos-estado.service.js' import { mondayService } from '../services/monday.service.js' 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() 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. Eventos reales: se responde 200 de inmediato y se * procesan en segundo plano. * * 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 if (typeof body?.['challenge'] === 'string') { res.json({ challenge: body['challenge'] }) return } const parsed = payloadSchema.safeParse(body) if (!parsed.success) { // eslint-disable-next-line no-console console.log('[webhook monday] payload sin estructura de evento reconocida; se ignora') res.sendStatus(200) return } 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=${idEvento ?? '?'}`) // 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) .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) }) // El lead entra también al funnel de Oportunidades (etapa 'Afluencia'). // El payload no trae nombre/teléfono: el servicio los consulta en Monday. void funnelService .asegurarOportunidadAfluenciaPorLeadId(id) .then((opId) => { // eslint-disable-next-line no-console console.log(`[webhook monday] afluencia item=${id}: oportunidad ${opId ?? 'no creada'}`) }) .catch((err: unknown) => { // eslint-disable-next-line no-console console.error(`[webhook monday] error en afluencia item=${id}:`, err) }) } // 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. const esEventoColumna = type === 'change_status_column_value' || type === 'change_column_value' || type === 'update_column_value' if (esEventoColumna && 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) }) } // Cambio de status en cualquier tablero con mapeo estado → grupo: el item se // mueve al grupo que corresponde a su nuevo estado (regla de negocio: los // grupos reflejan la etapa del pipeline). Mismo criterio que arriba: el // payload solo dispara y el servicio re-consulta el item antes de mover. // En Oportunidades convive con el escaneo (ambos disparan). if (esEventoColumna && idEvento !== undefined && GRUPOS_POR_ESTADO[String(boardId)]) { const board = String(boardId) const id = String(idEvento) void moverAGrupoSegunEstado(mondayService, board, id) .then((r) => { // eslint-disable-next-line no-console console.log(`[webhook monday] grupo-estado board=${board} item=${id}: ${r.movido ? `movido a ${r.grupo}` : 'sin cambio'}`) }) .catch((err: unknown) => { // eslint-disable-next-line no-console console.error(`[webhook monday] error en grupo-estado board=${board} item=${id}:`, err) }) } res.sendStatus(200) }