import cron from 'node-cron' import { crearApp } from './app.js' import { config } from './config/index.js' import { automatizacionesService } from './services/automatizaciones.service.js' import * as configService from './services/config.service.js' import { mondayService } from './services/monday.service.js' import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.service.js' import { saludMonday } from './services/salud-monday.service.js' import { syncMondayService } from './services/sync-monday.service.js' // Registra los handlers de cada tipo de SyncPendiente sobre el worker (efecto lateral) import './services/sync-handlers.js' const app = crearApp() // Wiring del circuit breaker: el ping vive en monday.service; se inyecta aquí // para que salud-monday.service no lo importe (ciclo de imports). saludMonday.registrarPing(() => mondayService.ping()) // Sondeo de salud de Monday (cada 60 s): tras FALLOS_PARA_CAER fallos GraphQL // seguidos el flag cae y la app opera en modo local; cuando el ping vuelve a // responder se recupera el flag y se drena la cola de sincronización. // Corre siempre (aunque MONDAY_AUTOMATIONS_ENABLED=false): es la infra del modo local. cron.schedule('* * * * *', () => { void (async () => { if (process.env.MODO_LOCAL_FORZADO === 'true') return // modo local forzado: no sondear if (saludMonday.estaDisponible()) return // Monday sano: nada que hacer const recuperado = await saludMonday.sondear() if (recuperado && saludMonday.estaDisponible()) { // eslint-disable-next-line no-console console.log('[salud] Monday recuperado; drenando cola de sincronización') void syncMondayService.procesarPendientes() // fire-and-forget (mutex interno) } })().catch((err: unknown) => console.error('[cron salud] error en sondeo:', err)) }) // Sync worker del modo local (cada 2 min): drena SyncPendiente FIFO hacia // Monday. No-op mientras Monday esté caído. Secuencial por diseño (SQLite). cron.schedule('*/2 * * * *', () => { syncMondayService .procesarPendientes() .catch((err: unknown) => console.error('[cron sync] error en la pasada:', err)) }) // Crons de respaldo: los webhooks de Monday (registrados sobre // 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) { // Polling: asigna vendedor (round-robin) a leads nuevos sin persona asignada, // 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. // 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, () => { void (async () => { // Modo local: Monday caído → saltar las reglas sin ruido (fallan seguro de todos modos) if (!saludMonday.estaDisponible()) { // eslint-disable-next-line no-console console.debug('[cron poll] Monday no disponible (modo local); pasada omitida') return } try { const r = await automatizacionesService.runRoundRobin() if (r.asignados > 0) { // eslint-disable-next-line no-console console.log(`[cron poll] round-robin: ${r.asignados} lead(s) asignado(s)`) } } catch (err: unknown) { console.error('[cron poll] error en round-robin:', err) } try { const r = await automatizacionesService.escaneoEscalado() if (r.escalados > 0) { // eslint-disable-next-line no-console console.log(`[cron poll] escalado: ${r.escalados} lead(s) escalado(s)`) } } catch (err: unknown) { console.error('[cron poll] error en escalado:', err) } try { const r = await automatizacionesService.escaneoOportunidades() if (r.apartadosCreados.length > 0 || r.ventasCreadas.length > 0 || r.errores.length > 0) { // eslint-disable-next-line no-console console.log( `[cron poll] oportunidades: ${r.apartadosCreados.length} apartado(s), ` + `${r.ventasCreadas.length} venta(s), ${r.errores.length} error(es)`, ) } } catch (err: unknown) { console.error('[cron poll] error en oportunidades:', err) } })() }) // Escaneo diario de leads inactivos (recordatorio vía update en el item) cron.schedule(config.automatizaciones.recordatorioCron, () => { if (!saludMonday.estaDisponible()) { // eslint-disable-next-line no-console console.debug('[cron inactividad] Monday no disponible (modo local); escaneo omitido') return } automatizacionesService .escaneoInactividad({ preview: false }) .then((r) => { // eslint-disable-next-line no-console console.log(`[cron inactividad] ${r.recordatoriosEnviados} recordatorio(s) enviado(s)`) }) .catch((err: unknown) => console.error('[cron inactividad] error en escaneo:', err)) }) // eslint-disable-next-line no-console console.log(`Automatizaciones Monday activas (poll: "${config.automatizaciones.pollCron}", inactividad: "${config.automatizaciones.recordatorioCron}")`) // Escaneo diario de recordatorios de cobranza (pagos por vencer / vencidos) cron.schedule(config.recordatoriosCobranza.cron, () => { recordatoriosCobranzaService .ejecutarPasada() .then((r) => { // eslint-disable-next-line no-console console.log( `[cron cobranza] recordatorios: ${r.enviados.length} enviado(s), ` + `${r.evaluados.filter((e) => e.decision === 'dedup').length} deduplicado(s), ${r.fallidos.length} fallido(s)`, ) }) .catch((err: unknown) => console.error('[cron cobranza] error en recordatorios:', err)) }) // eslint-disable-next-line no-console console.log(`Recordatorios de cobranza activos (cron: "${config.recordatoriosCobranza.cron}")`) } // Precarga de configuraciones desde DB (modo_local_forzado, etc.) void configService.refrescarCache().catch((err: unknown) => console.error('[startup] Error precargando config cache:', err), ) app.listen(config.port, () => { // eslint-disable-next-line no-console console.log(`API Gateway FRASA escuchando en http://localhost:${config.port} (${config.nodeEnv})`) })