Files
FRASA-CAS/api-gateway/src/services/sync-monday.service.ts
Consult AS Technology 60649890eb feat(modo-local): fallback completo sin Monday + cola de sincronización
- Circuit breaker saludMonday (3 fallos → modo local; ping cada 60s)
- Endpoint GET /api/sistema/estado {mondayOk, pendientesSync}
- LeadCaptura extendida (board, etapa, origen, motivo, pendienteSync)
- Tablas Unidad (espejo inventario, 288 seed) + SyncPendiente
- crmLocal: crearLead, afluencia, cambio etapa, descarte, conversión, apartar
- Ruteo en controllers: fallback local ante 502 o Monday caído
- Mi día local: resumen completo desde SQLite + unión con Monday
- Fallbacks: unidades, contactos, reportes, prefill, buscador
- Sync worker cada 2min + handlers 15 tipos (con DependenciaPendienteError)
- Gate automatizaciones: skip si !mondayOk
- Banner 'Modo local' en 5 apps + badge pendiente en mi-dia
- 597 API tests + 263 front tests verdes

Env MODO_LOCAL_FORZADO=true para testing sin Monday.
2026-09-06 08:08:56 +00:00

217 lines
7.8 KiB
TypeScript

import { AppError } from '../utils/errors.js'
import { prismaStateStore, type StateStore } from './automatizaciones.service.js'
import { mondayService, type MondayService } from './monday.service.js'
import { prisma } from './prisma.js'
import { saludMonday, type SaludMondayService } from './salud-monday.service.js'
/**
* Sync worker del modo local: drena la cola SyncPendiente hacia Monday.
*
* - Solo corre cuando Monday está disponible (saludMonday); si está caído es no-op.
* - FIFO por createdAt (la captura encoló en orden: lead → conversión →
* afluencia → apartado → contacto) y SECUENCIAL: SQLite serializa las
* escrituras (P1008) si se procesa en paralelo.
* - Idempotencia con AutomatizacionState, clave `sync:<id>` (mismo patrón que
* `apartado-creado:<id>` de automatizaciones): si el proceso muere a media
* pasada, al reintentar no duplica el item en Monday.
* - Fallo Monday (AppError 502): vuelve a PENDIENTE con intentos+1; al superar
* MAX_INTENTOS queda ERROR (visible en /api/sistema/estado). Cualquier otro
* error (tipo sin handler, payload inválido) marca ERROR de inmediato.
*
* Los handlers por tipo los registra la fase B (con los payloads reales de
* cada flujo) vía `registrarHandler`; el mapa queda extensible y defensivo.
*/
export const TIPOS_SYNC = [
'CREAR_LEAD',
'CREAR_AFLUENCIA',
'ACTUALIZAR_AFLUENCIA',
'CAMBIO_ETAPA',
'DESCARTE',
'CONVERSION',
'CREAR_CONTACTO',
'ACTUALIZAR_CONTACTO',
'CREAR_APARTADO',
'COTIZACION_OPORTUNIDAD',
'CHECKBOX_DOC',
'APARTO_OPORTUNIDAD',
'LIQUIDADO_ESCRITURAR',
'ASIGNAR_PERSONA',
'DISPONIBILIDAD_UNIDAD',
] as const
export type TipoSync = (typeof TIPOS_SYNC)[number]
/** Intentos máximos antes de declarar ERROR definitivo */
export const MAX_INTENTOS_SYNC = 10
/** Registro de la cola tal como lo consume el worker */
export interface SyncRegistro {
id: string
tipo: string
payloadJson: string
referenciaLocal: string | null
estado: string
intentos: number
createdAt: Date
}
/** Acceso mínimo a la tabla SyncPendiente (Prisma en prod, memoria en tests) */
export interface SyncStore {
/** Hasta `limite` registros PENDIENTE en orden FIFO (createdAt asc) */
pendientes(limite: number): Promise<SyncRegistro[]>
marcar(
id: string,
datos: { estado: string; intentos?: number; ultimoError?: string | null },
): Promise<void>
}
export interface ContextoSync {
monday: MondayService
referenciaLocal: string | null
}
/**
* Ejecuta la réplica en Monday. Devuelve el id creado (o cualquier cadena) para
* guardarlo en el dedup `sync:<id>`; void se persiste como 'ok'.
*/
export type HandlerSync = (
payload: Record<string, unknown>,
ctx: ContextoSync,
) => Promise<string | void>
export interface ResultadoSync {
procesados: number
fallos: number
}
/** true si el error es un fallo de Monday retryable (AppError 502 de graphql) */
export function esFalloMonday(err: unknown): boolean {
return err instanceof AppError && err.status === 502
}
/**
* El handler no puede ejecutarse TODAVÍA porque una operación previa de la
* cola aún no se sincroniza (p.ej. CAMBIO_ETAPA sobre un item cuyo CREATE no
* ha corrido: no hay mondayItemId). El worker lo deja PENDIENTE SIN sumar
* intento: se reintenta en la siguiente pasada, cuando su dependencia ya se
* haya aplicado (la cola es FIFO).
*/
export class DependenciaPendienteError extends Error {
constructor(message: string) {
super(message)
this.name = 'DependenciaPendienteError'
}
}
/** Clave de dedup/idempotencia del registro en AutomatizacionState */
export function claveSync(id: string): string {
return `sync:${id}`
}
export class SyncMondayService {
private readonly handlers = new Map<string, HandlerSync>()
/** Mutex en memoria: evita solapar el cron de 2 min con el disparo al recuperar salud */
private enEjecucion = false
constructor(
private readonly monday: MondayService,
private readonly state: StateStore,
private readonly db: SyncStore,
private readonly salud: Pick<SaludMondayService, 'estaDisponible'> = saludMonday,
) {}
/** Registra el handler de un tipo de réplica (lo usa la fase B por flujo) */
registrarHandler(tipo: TipoSync, handler: HandlerSync): void {
this.handlers.set(tipo, handler)
}
/**
* Una pasada por la cola (máx `limite` registros). No-op si Monday está
* caído / MODO_LOCAL_FORZADO, o si ya hay una pasada en curso.
*/
async procesarPendientes({ limite = 25 }: { limite?: number } = {}): Promise<ResultadoSync> {
if (!this.salud.estaDisponible()) return { procesados: 0, fallos: 0 }
if (this.enEjecucion) return { procesados: 0, fallos: 0 }
this.enEjecucion = true
try {
const pendientes = await this.db.pendientes(limite)
let procesados = 0
let fallos = 0
for (const registro of pendientes) {
await this.db.marcar(registro.id, { estado: 'PROCESANDO' })
try {
await this.ejecutar(registro)
await this.db.marcar(registro.id, { estado: 'HECHO', ultimoError: null })
procesados++
} catch (err) {
const mensaje = err instanceof Error ? err.message : String(err)
if (err instanceof DependenciaPendienteError) {
// Falta una operación previa de la cola: queda PENDIENTE sin sumar
// intento (no es un fallo; se reintenta en la próxima pasada)
await this.db.marcar(registro.id, { estado: 'PENDIENTE', ultimoError: mensaje })
} else {
fallos++
const intentos = registro.intentos + 1
if (esFalloMonday(err)) {
// Monday volvió a caer a media pasada: reintenta en la siguiente
await this.db.marcar(registro.id, {
estado: intentos > MAX_INTENTOS_SYNC ? 'ERROR' : 'PENDIENTE',
intentos,
ultimoError: mensaje,
})
} else {
// Error de datos/código: reintentar no lo arregla
await this.db.marcar(registro.id, { estado: 'ERROR', intentos, ultimoError: mensaje })
}
}
}
}
if (procesados + fallos > 0) {
// eslint-disable-next-line no-console
console.log(`[sync] procesados ${procesados}, fallos ${fallos}`)
}
return { procesados, fallos }
} finally {
this.enEjecucion = false
}
}
/** Ejecuta un registro con dedup `sync:<id>`; lanza para que el caller clasifique */
private async ejecutar(registro: SyncRegistro): Promise<void> {
const handler = this.handlers.get(registro.tipo)
if (!handler) throw new Error(`Tipo de sync sin handler registrado: "${registro.tipo}"`)
let payload: Record<string, unknown>
try {
payload = JSON.parse(registro.payloadJson) as Record<string, unknown>
} catch {
throw new Error(`payloadJson inválido (no es JSON): registro ${registro.id}`)
}
const clave = claveSync(registro.id)
const previo = await this.state.get(clave)
if (previo !== null) return // ya se aplicó en una pasada previa (dedup)
const resultado = await handler(payload, {
monday: this.monday,
referenciaLocal: registro.referenciaLocal,
})
await this.state.set(clave, typeof resultado === 'string' && resultado !== '' ? resultado : 'ok')
}
}
/** Store respaldado por la tabla SyncPendiente de Prisma */
export const prismaSyncStore: SyncStore = {
pendientes(limite) {
return prisma.syncPendiente.findMany({
where: { estado: 'PENDIENTE' },
orderBy: { createdAt: 'asc' },
take: limite,
})
},
async marcar(id, datos) {
await prisma.syncPendiente.update({ where: { id }, data: datos })
},
}
/** Singleton usado por los crons de index.ts (los tests instancian el suyo) */
export const syncMondayService = new SyncMondayService(mondayService, prismaStateStore, prismaSyncStore)