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:
@@ -48,6 +48,9 @@ JWT_EXPIRES_IN="7d"
|
|||||||
AUTH_ENABLED=true
|
AUTH_ENABLED=true
|
||||||
# Password inicial del usuario 'admin' sembrado por npm run seed:usuarios
|
# Password inicial del usuario 'admin' sembrado por npm run seed:usuarios
|
||||||
ADMIN_PASSWORD="Frasa2026"
|
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
|
# APP CONFIG
|
||||||
|
|||||||
Binary file not shown.
@@ -115,6 +115,42 @@ def main():
|
|||||||
print(f'{aplicados} items actualizados en Monday')
|
print(f'{aplicados} items actualizados en Monday')
|
||||||
for code, motivo in omitidos:
|
for code, motivo in omitidos:
|
||||||
print(f' omitido {code}: {motivo}')
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export const config = {
|
|||||||
nodeEnv: env('NODE_ENV', 'development'),
|
nodeEnv: env('NODE_ENV', 'development'),
|
||||||
databaseUrl: env('DATABASE_URL', 'file:./dev.db'),
|
databaseUrl: env('DATABASE_URL', 'file:./dev.db'),
|
||||||
uploadsDir: path.resolve(process.cwd(), env('UPLOADS_DIR', './uploads')),
|
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: {
|
monday: {
|
||||||
apiKey: env('MONDAY_API_KEY'),
|
apiKey: env('MONDAY_API_KEY'),
|
||||||
apiUrl: env('MONDAY_API_URL', 'https://api.monday.com/v2'),
|
apiUrl: env('MONDAY_API_URL', 'https://api.monday.com/v2'),
|
||||||
|
|||||||
112
api-gateway/src/controllers/webhooks.controller.test.ts
Normal file
112
api-gateway/src/controllers/webhooks.controller.test.ts
Normal 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))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,7 +7,10 @@ const eventoSchema = z
|
|||||||
.object({
|
.object({
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
boardId: z.union([z.string(), z.number()]),
|
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(),
|
itemId: z.union([z.string(), z.number()]).optional(),
|
||||||
|
pulseId: z.union([z.string(), z.number()]).optional(),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
@@ -39,14 +42,18 @@ export function webhookMonday(req: Request, res: Response): void {
|
|||||||
return
|
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 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
|
// 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) {
|
// Monday registra el webhook como 'create_item' pero el payload real llega
|
||||||
const id = String(itemId)
|
// 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
|
// Fire-and-forget: la respuesta 200 no espera a que termine la asignación
|
||||||
void automatizacionesService
|
void automatizacionesService
|
||||||
.runRoundRobin(id)
|
.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)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,36 +6,40 @@ import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.
|
|||||||
|
|
||||||
const app = crearApp()
|
const app = crearApp()
|
||||||
|
|
||||||
// Crons de respaldo (el webhook público aún no existe: no hay SSL ni DNS).
|
// Crons de respaldo: los webhooks de Monday (registrados sobre
|
||||||
// Se desactivan con MONDAY_AUTOMATIONS_ENABLED=false.
|
// 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) {
|
if (config.automatizaciones.enabled) {
|
||||||
// Polling: asigna vendedor (round-robin) a leads nuevos sin persona asignada,
|
// Polling: asigna vendedor (round-robin) a leads nuevos sin persona asignada,
|
||||||
// escala leads sin contactar y crea Apartados/Ventas al moverse Oportunidades.
|
// 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.
|
// 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, () => {
|
cron.schedule(config.automatizaciones.pollCron, () => {
|
||||||
automatizacionesService
|
void (async () => {
|
||||||
.runRoundRobin()
|
try {
|
||||||
.then((r) => {
|
const r = await automatizacionesService.runRoundRobin()
|
||||||
if (r.asignados > 0) {
|
if (r.asignados > 0) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[cron poll] round-robin: ${r.asignados} lead(s) asignado(s)`)
|
console.log(`[cron poll] round-robin: ${r.asignados} lead(s) asignado(s)`)
|
||||||
}
|
}
|
||||||
})
|
} catch (err: unknown) {
|
||||||
.catch((err: unknown) => console.error('[cron poll] error en round-robin:', err))
|
console.error('[cron poll] error en round-robin:', err)
|
||||||
|
}
|
||||||
|
|
||||||
automatizacionesService
|
try {
|
||||||
.escaneoEscalado()
|
const r = await automatizacionesService.escaneoEscalado()
|
||||||
.then((r) => {
|
|
||||||
if (r.escalados > 0) {
|
if (r.escalados > 0) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[cron poll] escalado: ${r.escalados} lead(s) escalado(s)`)
|
console.log(`[cron poll] escalado: ${r.escalados} lead(s) escalado(s)`)
|
||||||
}
|
}
|
||||||
})
|
} catch (err: unknown) {
|
||||||
.catch((err: unknown) => console.error('[cron poll] error en escalado:', err))
|
console.error('[cron poll] error en escalado:', err)
|
||||||
|
}
|
||||||
|
|
||||||
automatizacionesService
|
try {
|
||||||
.escaneoOportunidades()
|
const r = await automatizacionesService.escaneoOportunidades()
|
||||||
.then((r) => {
|
|
||||||
if (r.apartadosCreados.length > 0 || r.ventasCreadas.length > 0 || r.errores.length > 0) {
|
if (r.apartadosCreados.length > 0 || r.ventasCreadas.length > 0 || r.errores.length > 0) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(
|
console.log(
|
||||||
@@ -43,8 +47,10 @@ if (config.automatizaciones.enabled) {
|
|||||||
`${r.ventasCreadas.length} venta(s), ${r.errores.length} error(es)`,
|
`${r.ventasCreadas.length} venta(s), ${r.errores.length} error(es)`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
} catch (err: unknown) {
|
||||||
.catch((err: unknown) => console.error('[cron poll] error en oportunidades:', err))
|
console.error('[cron poll] error en oportunidades:', err)
|
||||||
|
}
|
||||||
|
})()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Escaneo diario de leads inactivos (recordatorio vía update en el item)
|
// Escaneo diario de leads inactivos (recordatorio vía update en el item)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ vi.mock('../config/index.js', () => ({
|
|||||||
jwtExpiresIn: '7d',
|
jwtExpiresIn: '7d',
|
||||||
adminPassword: 'Frasa2026',
|
adminPassword: 'Frasa2026',
|
||||||
},
|
},
|
||||||
|
internalSyncToken: 'test-internal-token',
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -129,4 +130,42 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => {
|
|||||||
authMiddleware(reqFake({ method: 'GET', path: '/webhooks/monday' }), res, next)
|
authMiddleware(reqFake({ method: 'GET', path: '/webhooks/monday' }), res, next)
|
||||||
expect(status).toHaveBeenCalledWith(401)
|
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<Request>[] = [
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
|
|||||||
return
|
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 header = req.headers.authorization
|
||||||
const token = header?.startsWith('Bearer ') ? header.slice('Bearer '.length).trim() : null
|
const token = header?.startsWith('Bearer ') ? header.slice('Bearer '.length).trim() : null
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|||||||
@@ -3,12 +3,48 @@
|
|||||||
Recetas que se activan **desde la UI de Monday** (botón "Automatizar" en cada
|
Recetas que se activan **desde la UI de Monday** (botón "Automatizar" en cada
|
||||||
tablero). Tiempo estimado total: ~10 minutos. Ninguna requiere código.
|
tablero). Tiempo estimado total: ~10 minutos. Ninguna requiere código.
|
||||||
|
|
||||||
> **No duplicar**: el servidor (API Gateway, crons cada 5 min) ya cubre la
|
> **No duplicar**: el servidor (API Gateway) ya cubre la asignación round-robin
|
||||||
> asignación round-robin de vendedores, el recordatorio de leads inactivos
|
> de vendedores, el recordatorio de leads inactivos (10 días), el escalado de
|
||||||
> (10 días), el escalado de leads sin contactar (48 h), y la creación de items
|
> leads sin contactar (48 h), y la creación de items en Apartado / Ventas
|
||||||
> en Apartado / Ventas cuando una oportunidad entra a **Aparto** o **Ganado**.
|
> cuando una oportunidad entra a **Aparto** o **Ganado**. Las recetas de abajo
|
||||||
> Las recetas de abajo son solo *avisos y orden visual*; no repitas esas reglas
|
> son solo *avisos y orden visual*; no repitas esas reglas como recetas
|
||||||
> como recetas nativas.
|
> 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: <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
|
## 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
|
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.
|
la notificación de la receta 1. Bórralo después.
|
||||||
2. En **Oportunidades**, cambia un item de prueba a **Ganado** → notificación
|
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
|
de la receta 2 **y** (en segundos, vía webhook; ≤ 5 min por el cron de
|
||||||
**Ventas**. Borra ambos después.
|
respaldo) un item nuevo en **Ventas**. Borra ambos después.
|
||||||
|
|||||||
Reference in New Issue
Block a user