Files
FRASA-CAS/api-gateway/src/controllers/webhooks.controller.ts
Consult AS Technology 89fcd1c579 feat(monday): mover items al grupo correcto al cambiar de estado
Webhook change_column_value en los 5 tableros (Leads, Oportunidades,
Unidades, Apartado, Contactos) + mapeo central estado->grupo: el API
re-consulta el item y lo mueve con move_item_to_group (idempotente).
El funnel mueve la oportunidad de Afluencia al crearla (Monday la crea
en el grupo top del board). Incluye script de backfill one-off
(249 items reubicados) y tests del mapeo y del webhook.
2026-09-03 00:14:18 +00:00

130 lines
5.5 KiB
TypeScript

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<string, unknown>
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)
}