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.
This commit is contained in:
216
api-gateway/src/services/sync-monday.service.ts
Normal file
216
api-gateway/src/services/sync-monday.service.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user