feat(api): automatizaciones Monday — round-robin de leads, recordatorio inactividad 10d, webhooks reales y polling cron

- Servicio de automatizaciones con estado persistido en SQLite (índice RR, dedup de recordatorios)
- Webhook Monday procesa create_item del board Leads y asigna vendedor
- Crons: polling cada 5 min (respaldo sin webhook público) + escaneo diario 9am
- Endpoints admin: /api/automatizaciones/estado, /round-robin/run, /inactividad/preview
- 15 tests nuevos (70/70 verdes)
This commit is contained in:
2026-08-22 02:37:16 +00:00
parent 05f6ceb01a
commit 8407fc24c3
13 changed files with 781 additions and 12 deletions

View File

@@ -0,0 +1,17 @@
import type { Request, Response } from 'express'
import { automatizacionesService } from '../services/automatizaciones.service.js'
/** GET /api/automatizaciones/estado — índice RR, vendedores, último escaneo */
export async function estadoAutomatizaciones(_req: Request, res: Response): Promise<void> {
res.json(await automatizacionesService.estado())
}
/** POST /api/automatizaciones/round-robin/run — pasada manual de asignación */
export async function correrRoundRobin(_req: Request, res: Response): Promise<void> {
res.json(await automatizacionesService.runRoundRobin())
}
/** GET /api/automatizaciones/inactividad/preview — leads que serían recordados hoy (sin escribir) */
export async function previewInactividad(_req: Request, res: Response): Promise<void> {
res.json(await automatizacionesService.escaneoInactividad({ preview: true }))
}

View File

@@ -1,22 +1,27 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
const eventoSchema = z
.object({
type: z.string().optional(),
boardId: z.union([z.string(), z.number()]).optional(),
type: z.string(),
boardId: z.union([z.string(), z.number()]),
itemId: 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. Para eventos reales solo se loggea y se responde 200.
* 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. Antes de
* actuar sobre un evento hay que re-consultar el estado vía API (por ahora
* solo se registra el evento).
* 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>
@@ -26,11 +31,34 @@ export function webhookMonday(req: Request, res: Response): void {
return
}
const parsed = eventoSchema.safeParse(body)
if (parsed.success) {
const { type, boardId, itemId } = parsed.data
const parsed = payloadSchema.safeParse(body)
if (!parsed.success) {
// eslint-disable-next-line no-console
console.log(`[webhook monday] tipo=${type ?? 'desconocido'} board=${boardId ?? '?'} item=${itemId ?? '?'}`)
console.log('[webhook monday] payload sin estructura de evento reconocida; se ignora')
res.sendStatus(200)
return
}
const { type, boardId, itemId } = parsed.data.event
const esBoardLeads = String(boardId) === config.monday.boards.leads
// eslint-disable-next-line no-console
console.log(`[webhook monday] tipo=${type} board=${boardId} item=${itemId ?? '?'}`)
if (type === 'create_item' && esBoardLeads && itemId !== undefined) {
const id = String(itemId)
// 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)
})
}
res.sendStatus(200)
}