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:
@@ -1,15 +1,28 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
import { detalleContacto, claveTelefono } from '../services/contactos-detalle.service.js'
|
||||
import { detalleContactoLocal, listarContactosLocal } from '../services/contactos-local.service.js'
|
||||
import { saludMonday } from '../services/salud-monday.service.js'
|
||||
import { esFalloMonday } from '../services/sync-monday.service.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
|
||||
/**
|
||||
* GET /api/contactos (Bearer) → contactos del tablero Contactos de Monday
|
||||
* (nombre, teléfono, email, estado), lo más reciente primero, máx 100.
|
||||
* Monday caído (o 502) → lista local (Cliente + LeadCaptura), mismo shape.
|
||||
*/
|
||||
export async function listarContactosEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await mondayService.getContactos())
|
||||
if (!saludMonday.estaDisponible()) {
|
||||
res.json(await listarContactosLocal())
|
||||
return
|
||||
}
|
||||
try {
|
||||
res.json(await mondayService.getContactos())
|
||||
} catch (err) {
|
||||
if (!esFalloMonday(err)) throw err
|
||||
res.json(await listarContactosLocal())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,74 +34,91 @@ export async function listarContactosEndpoint(_req: Request, res: Response): Pro
|
||||
export async function detalleContactoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const contactoId = String(req.params['id'])
|
||||
|
||||
const contacto = await mondayService.getContactoDetalle(contactoId)
|
||||
if (contacto === null) throw new NotFoundError('Contacto no encontrado')
|
||||
|
||||
// Nombres para "atendidoPor"/historial: usuarios del ecosistema + workspace
|
||||
const mapeados = await prisma.usuario.findMany({
|
||||
where: { mondayUserId: { not: null }, activo: true },
|
||||
select: { nombre: true, mondayUserId: true },
|
||||
})
|
||||
const nombresPorMondayId = new Map<number, string>(
|
||||
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
|
||||
)
|
||||
try {
|
||||
for (const u of await mondayService.getUsuariosWorkspace()) {
|
||||
if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre)
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('No se pudieron listar los usuarios del workspace de Monday:', err)
|
||||
// Monday caído (circuit breaker): detalle 100% local
|
||||
if (!saludMonday.estaDisponible()) {
|
||||
const detalle = await detalleContactoLocal(contactoId)
|
||||
if (detalle === null) throw new NotFoundError('Contacto no encontrado')
|
||||
res.json(detalle)
|
||||
return
|
||||
}
|
||||
|
||||
// Registro local: capturas con el mismo teléfono (Monday no guarda quién
|
||||
// capturó) y la ficha de cliente ligada a este contacto (o por celular).
|
||||
// LeadCaptura/Cliente no tienen relación Prisma a Usuario: se resuelve a mano.
|
||||
const clave = claveTelefono(contacto['Teléfono'] ?? '')
|
||||
const capturasTodas = await prisma.leadCaptura.findMany({
|
||||
where: { telefono: { not: null } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const capturasCrudas = capturasTodas.filter((c) => c.telefono !== null && clave !== '' && claveTelefono(c.telefono) === clave)
|
||||
try {
|
||||
const contacto = await mondayService.getContactoDetalle(contactoId)
|
||||
if (contacto === null) throw new NotFoundError('Contacto no encontrado')
|
||||
|
||||
const clienteCrudo = await prisma.cliente.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ mondayContactoId: contactoId },
|
||||
...(clave !== '' ? [{ celular: { contains: clave } }] : []),
|
||||
],
|
||||
},
|
||||
include: { _count: { select: { documentos: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// Nombres de capturistas (ids locales → nombre para mostrar)
|
||||
const idsUsuarios = [
|
||||
...new Set([
|
||||
...capturasCrudas.map((c) => c.usuarioId),
|
||||
...(clienteCrudo?.capturadoPorId ? [clienteCrudo.capturadoPorId] : []),
|
||||
]),
|
||||
]
|
||||
const usuariosLocal = idsUsuarios.length
|
||||
? await prisma.usuario.findMany({ where: { id: { in: idsUsuarios } }, select: { id: true, nombre: true } })
|
||||
: []
|
||||
const nombreLocal = new Map(usuariosLocal.map((u) => [u.id, u.nombre]))
|
||||
|
||||
const capturas = capturasCrudas.map((c) => ({
|
||||
leadId: c.leadId,
|
||||
nombreLead: c.nombreLead,
|
||||
createdAt: c.createdAt,
|
||||
usuario: nombreLocal.has(c.usuarioId) ? { nombre: nombreLocal.get(c.usuarioId)! } : null,
|
||||
}))
|
||||
const clienteLocal = clienteCrudo
|
||||
? {
|
||||
...clienteCrudo,
|
||||
capturadoPor:
|
||||
clienteCrudo.capturadoPorId && nombreLocal.has(clienteCrudo.capturadoPorId)
|
||||
? { nombre: nombreLocal.get(clienteCrudo.capturadoPorId)! }
|
||||
: null,
|
||||
// Nombres para "atendidoPor"/historial: usuarios del ecosistema + workspace
|
||||
const mapeados = await prisma.usuario.findMany({
|
||||
where: { mondayUserId: { not: null }, activo: true },
|
||||
select: { nombre: true, mondayUserId: true },
|
||||
})
|
||||
const nombresPorMondayId = new Map<number, string>(
|
||||
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
|
||||
)
|
||||
try {
|
||||
for (const u of await mondayService.getUsuariosWorkspace()) {
|
||||
if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre)
|
||||
}
|
||||
: null
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('No se pudieron listar los usuarios del workspace de Monday:', err)
|
||||
}
|
||||
|
||||
res.json(await detalleContacto(mondayService, contactoId, nombresPorMondayId, capturas, clienteLocal))
|
||||
// Registro local: capturas con el mismo teléfono (Monday no guarda quién
|
||||
// capturó) y la ficha de cliente ligada a este contacto (o por celular).
|
||||
// LeadCaptura/Cliente no tienen relación Prisma a Usuario: se resuelve a mano.
|
||||
const clave = claveTelefono(contacto['Teléfono'] ?? '')
|
||||
const capturasTodas = await prisma.leadCaptura.findMany({
|
||||
where: { telefono: { not: null } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const capturasCrudas = capturasTodas.filter((c) => c.telefono !== null && clave !== '' && claveTelefono(c.telefono) === clave)
|
||||
|
||||
const clienteCrudo = await prisma.cliente.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ mondayContactoId: contactoId },
|
||||
...(clave !== '' ? [{ celular: { contains: clave } }] : []),
|
||||
],
|
||||
},
|
||||
include: { _count: { select: { documentos: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// Nombres de capturistas (ids locales → nombre para mostrar)
|
||||
const idsUsuarios = [
|
||||
...new Set([
|
||||
...capturasCrudas.map((c) => c.usuarioId),
|
||||
...(clienteCrudo?.capturadoPorId ? [clienteCrudo.capturadoPorId] : []),
|
||||
]),
|
||||
]
|
||||
const usuariosLocal = idsUsuarios.length
|
||||
? await prisma.usuario.findMany({ where: { id: { in: idsUsuarios } }, select: { id: true, nombre: true } })
|
||||
: []
|
||||
const nombreLocal = new Map(usuariosLocal.map((u) => [u.id, u.nombre]))
|
||||
|
||||
const capturas = capturasCrudas.map((c) => ({
|
||||
leadId: c.leadId,
|
||||
nombreLead: c.nombreLead,
|
||||
createdAt: c.createdAt,
|
||||
usuario: nombreLocal.has(c.usuarioId) ? { nombre: nombreLocal.get(c.usuarioId)! } : null,
|
||||
}))
|
||||
const clienteLocal = clienteCrudo
|
||||
? {
|
||||
...clienteCrudo,
|
||||
capturadoPor:
|
||||
clienteCrudo.capturadoPorId && nombreLocal.has(clienteCrudo.capturadoPorId)
|
||||
? { nombre: nombreLocal.get(clienteCrudo.capturadoPorId)! }
|
||||
: null,
|
||||
}
|
||||
: null
|
||||
|
||||
res.json(await detalleContacto(mondayService, contactoId, nombresPorMondayId, capturas, clienteLocal))
|
||||
} catch (err) {
|
||||
// Monday se cayó a media lectura: detalle local (el 404 de "no existe en
|
||||
// Monday" NO es fallo de Monday y se propaga intacto)
|
||||
if (!esFalloMonday(err)) throw err
|
||||
const detalle = await detalleContactoLocal(contactoId)
|
||||
if (detalle === null) throw new NotFoundError('Contacto no encontrado')
|
||||
res.json(detalle)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user