diff --git a/.env.example b/.env.example index e527ac9..e72a01e 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,9 @@ INACTIVIDAD_DIAS="10" POLL_CRON="*/5 * * * *" # 'false' desactiva los crons (los endpoints /api/automatizaciones siguen vivos) MONDAY_AUTOMATIONS_ENABLED="true" +# 'true' fuerza el modo local aunque Monday responda (pruebas del modo degradado): +# /api/sistema/estado reporta mondayOk=false y el sync worker no procesa la cola +MODO_LOCAL_FORZADO="false" # ============================================ # BASE DE DATOS (fase API Gateway) diff --git a/api-gateway/package.json b/api-gateway/package.json index df8bb97..99733a1 100644 --- a/api-gateway/package.json +++ b/api-gateway/package.json @@ -14,6 +14,7 @@ "test": "vitest run", "seed:ai": "tsx scripts/seed-ai.ts", "seed:usuarios": "tsx scripts/seed-usuarios.ts", + "seed:unidades": "tsx scripts/seed-unidades-locales.js", "setup:crm": "tsx scripts/setup-crm.ts", "sync:desarrollos": "tsx scripts/sync-desarrollos.ts", "sync:etapas": "tsx scripts/sync-etapas.ts", diff --git a/api-gateway/prisma/migrations/20260905120000_modo_local_fundacion/migration.sql b/api-gateway/prisma/migrations/20260905120000_modo_local_fundacion/migration.sql new file mode 100644 index 0000000..4049ed9 --- /dev/null +++ b/api-gateway/prisma/migrations/20260905120000_modo_local_fundacion/migration.sql @@ -0,0 +1,72 @@ +-- Modo local (degradado) con sincronización a Monday: +-- - LeadCaptura pasa a ser la entidad local del embudo (board/etapa/perfil + +-- bandera pendienteSync). +-- - Unidad: espejo local del inventario (fallback del cotizador). +-- - SyncPendiente: cola FIFO de réplicas a Monday. +-- Patrón manual (drift histórico): db execute + migrate resolve. NUNCA migrate dev. + +-- AlterTable LeadCaptura (SQLite no soporta IF NOT EXISTS en ADD COLUMN; +-- columnas verificadas ausentes con PRAGMA table_info antes de escribir esto) +ALTER TABLE "LeadCaptura" ADD COLUMN "board" TEXT NOT NULL DEFAULT 'LEADS'; +ALTER TABLE "LeadCaptura" ADD COLUMN "etapa" TEXT NOT NULL DEFAULT 'NUEVO'; +ALTER TABLE "LeadCaptura" ADD COLUMN "origen" TEXT; +ALTER TABLE "LeadCaptura" ADD COLUMN "desarrollo" TEXT; +ALTER TABLE "LeadCaptura" ADD COLUMN "modelo" TEXT; +ALTER TABLE "LeadCaptura" ADD COLUMN "email" TEXT; +ALTER TABLE "LeadCaptura" ADD COLUMN "motivo" TEXT; +ALTER TABLE "LeadCaptura" ADD COLUMN "submotivo" TEXT; +ALTER TABLE "LeadCaptura" ADD COLUMN "datosJson" TEXT; +ALTER TABLE "LeadCaptura" ADD COLUMN "mondayItemId" TEXT; +ALTER TABLE "LeadCaptura" ADD COLUMN "pendienteSync" BOOLEAN NOT NULL DEFAULT false; +-- SQLite no admite DEFAULT CURRENT_TIMESTAMP en ADD COLUMN: default constante +-- + backfill. Prisma siempre escribe updatedAt al crear/actualizar (@updatedAt). +ALTER TABLE "LeadCaptura" ADD COLUMN "updatedAt" DATETIME NOT NULL DEFAULT '2026-09-05 00:00:00'; + +-- Backfill: rows viejas ya estaban en Monday; su item es leadId +UPDATE "LeadCaptura" SET "updatedAt" = CURRENT_TIMESTAMP, "mondayItemId" = "leadId"; + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "LeadCaptura_mondayItemId_idx" ON "LeadCaptura"("mondayItemId"); + +-- CreateTable Unidad +CREATE TABLE IF NOT EXISTS "Unidad" ( + "id" TEXT NOT NULL PRIMARY KEY, + "codigo" TEXT NOT NULL, + "desarrollo" TEXT NOT NULL, + "modelo" TEXT NOT NULL DEFAULT '', + "manzana" TEXT NOT NULL DEFAULT '', + "lote" TEXT NOT NULL DEFAULT '', + "supConstruccion" REAL NOT NULL DEFAULT 0, + "supExcedente" REAL NOT NULL DEFAULT 0, + "supEstacionamiento" REAL NOT NULL DEFAULT 0, + "precioLista" REAL NOT NULL DEFAULT 0, + "precioAcabados" REAL NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'DISPONIBLE', + "etapa" TEXT NOT NULL DEFAULT '', + "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "Unidad_codigo_key" ON "Unidad"("codigo"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Unidad_desarrollo_idx" ON "Unidad"("desarrollo"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Unidad_status_idx" ON "Unidad"("status"); + +-- CreateTable SyncPendiente +CREATE TABLE IF NOT EXISTS "SyncPendiente" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tipo" TEXT NOT NULL, + "payloadJson" TEXT NOT NULL, + "referenciaLocal" TEXT, + "estado" TEXT NOT NULL DEFAULT 'PENDIENTE', + "intentos" INTEGER NOT NULL DEFAULT 0, + "ultimoError" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "SyncPendiente_estado_createdAt_idx" ON "SyncPendiente"("estado", "createdAt"); diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 4629505..d5baacf 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -157,16 +157,86 @@ model LeadOportunidad { // Registro local de qué usuario del ecosistema capturó cada lead (alta de // afluencia). Monday no guarda al capturista (el creador del item siempre es // el token del API); esta tabla alimenta "Mis capturas" del panel Mi día. +// Además es la entidad local del embudo en modo local (Monday caído): guarda +// el board/etapa actuales y el perfil de la captura hasta que el sync worker +// la replique en Monday. model LeadCaptura { id String @id @default(uuid()) - leadId String @unique // item del tablero Leads (Monday) + leadId String @unique // item del tablero Leads (Monday); en modo local: id local loc_ oportunidadId String? // item del tablero Oportunidades ligado al capturar usuarioId String // Usuario.id del capturista nombreLead String telefono String? + // Board del embudo donde vive el registro: LEADS | OPORTUNIDADES | APARTADO + board String @default("LEADS") + // Etapa/estado local actual (NUEVO al capturar) + etapa String @default("NUEVO") + origen String? + desarrollo String? + modelo String? + email String? + // Motivo / submotivo del descarte + motivo String? + submotivo String? + // Perfilamiento completo de afluencia / payload extra (JSON serializado) + datosJson String? + // Item de Monday una vez sincronizado (rows viejas: = leadId) + mondayItemId String? + // true = falta replicar a Monday (lo drena el sync worker) + pendienteSync Boolean @default(false) createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @default(now()) @@index([usuarioId]) + @@index([mondayItemId]) +} + +// Espejo local del inventario (tablero Unidades de Monday). Lo siembra +// scripts/seed-unidades-locales.js desde el INVENTARIO_SEED del cotizador y lo +// mantiene fresco el write-through de monday.service.getUnidades(); es el +// fallback de lectura del cotizador en modo local. +model Unidad { + id String @id @default(uuid()) + codigo String @unique // 'U-101', 'ATELIER-10', 'SENDA-411-1-7', ... + desarrollo String // DIAGONAL | ATELIER | ESTANZA | SENDA | SOLANA + modelo String @default("") + manzana String @default("") + lote String @default("") + supConstruccion Float @default(0) + supExcedente Float @default(0) + supEstacionamiento Float @default(0) + precioLista Float @default(0) + precioAcabados Float @default(0) + status String @default("DISPONIBLE") // DISPONIBLE | APARTADO | VENDIDO | ESCRITURADO | BLOQUEADO | ... + etapa String @default("") // etapa del pipeline de venta ('' si no aplica) + updatedAt DateTime @updatedAt @default(now()) + + @@index([desarrollo]) + @@index([status]) +} + +// Cola de sincronización a Monday (modo local). Cada operación hecha contra la +// base local mientras Monday está caído se encola aquí; sync-monday.service la +// drena FIFO (createdAt asc) cuando Monday vuelve. Estados: PENDIENTE | +// PROCESANDO | HECHO | ERROR. Idempotencia vía AutomatizacionState 'sync:'. +model SyncPendiente { + id String @id @default(uuid()) + // 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 + tipo String + payloadJson String + // Referencia local (leadId / clienteId / cotizacionId) para actualizar ids + referenciaLocal String? + estado String @default("PENDIENTE") + intentos Int @default(0) + ultimoError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([estado, createdAt]) } // Estado persistente de las automatizaciones de Monday (round-robin, recordatorios). diff --git a/api-gateway/scripts/seed-unidades-locales.js b/api-gateway/scripts/seed-unidades-locales.js new file mode 100644 index 0000000..500f6c1 --- /dev/null +++ b/api-gateway/scripts/seed-unidades-locales.js @@ -0,0 +1,77 @@ +/** + * Siembra la tabla local `Unidad` (espejo del inventario para el modo local) + * desde el INVENTARIO_SEED curado del cotizador + * (apps/cotizador/src/data/unidades.ts, generado de los Excel de precios). + * + * Idempotente: upsert por `codigo`. Se corre una vez en el deploy y cuando el + * seed del cotizador se regenere; el write-through de + * monday.service.getUnidades() mantiene fresco el espejo en operación normal. + * + * Uso: npm run seed:unidades (tsx, porque la fuente es TypeScript) + */ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import dotenv from 'dotenv' +import { PrismaClient } from '@prisma/client' +import { INVENTARIO_SEED } from '../../apps/cotizador/src/data/unidades.ts' + +const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] }) + +const prisma = new PrismaClient() + +const numero = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0) +const texto = (v) => (v === null || v === undefined ? '' : String(v)) + +function mapearUnidad(u) { + const extras = u.extras ?? {} + return { + codigo: u.codigo, + desarrollo: texto(u.desarrollo) || 'DIAGONAL', + modelo: texto(u.modelo), + manzana: texto(extras.manzana), + lote: texto(extras.lote), + supConstruccion: numero(u.supConstruccion), + supExcedente: numero(u.supExcedente), + supEstacionamiento: numero(u.supEstacionamiento), + precioLista: numero(u.precioSinAcabados), + precioAcabados: numero(u.precioConAcabados), + status: texto(u.status) || 'DISPONIBLE', + // En SOLANA la etapa de obra vive en extras.etapa; en el tablero Monday es + // la columna "Etapa" (top-level en el tipo del cotizador) + etapa: texto(u.etapa) || texto(extras.etapa), + } +} + +async function main() { + let insertadas = 0 + let actualizadas = 0 + for (const cruda of INVENTARIO_SEED) { + const u = mapearUnidad(cruda) + const previa = await prisma.unidad.findUnique({ where: { codigo: u.codigo }, select: { id: true } }) + await prisma.unidad.upsert({ + where: { codigo: u.codigo }, + update: u, + create: u, + }) + if (previa) actualizadas++ + else insertadas++ + } + const total = await prisma.unidad.count() + const porDesarrollo = await prisma.unidad.groupBy({ by: ['desarrollo'], _count: { codigo: true } }) + // eslint-disable-next-line no-console + console.log( + `[seed-unidades] ${insertadas} insertada(s), ${actualizadas} actualizada(s); total en tabla: ${total}`, + ) + for (const d of porDesarrollo) { + // eslint-disable-next-line no-console + console.log(` ${d.desarrollo}: ${d._count.codigo}`) + } +} + +main() + .catch((err) => { + console.error('[seed-unidades] error:', err) + process.exitCode = 1 + }) + .finally(() => prisma.$disconnect()) diff --git a/api-gateway/src/controllers/apartados.controller.test.ts b/api-gateway/src/controllers/apartados.controller.test.ts index 9c6912c..bf8a3fe 100644 --- a/api-gateway/src/controllers/apartados.controller.test.ts +++ b/api-gateway/src/controllers/apartados.controller.test.ts @@ -1,9 +1,37 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' import { cambiarEstadoApartado, + cambiarEstadoApartadoEndpoint, FASES_APARTADO_PERMITIDAS, + subirDocumentosApartado, TIPOS_DOCUMENTO_APARTADO, } from './apartados.controller.js' +import { saludMonday } from '../services/salud-monday.service.js' + +// ---------- Prisma mockeado (los tests no tocan la BD) ---------- + +const prismaMock = vi.hoisted(() => ({ + leadCaptura: { + create: vi.fn(), + findFirst: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + cliente: { + create: vi.fn(), + findFirst: vi.fn(), + }, + documento: { + create: vi.fn(), + }, + syncPendiente: { + create: vi.fn(), + }, + $transaction: vi.fn(), +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) /** Monday mockeado: registra cambios de columna y movimientos de grupo */ function mondayMock(grupoActual = 'topics') { @@ -71,3 +99,85 @@ describe('cambiarEstadoApartado (POST /api/apartados/:id/estado)', () => { expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO']) }) }) + +// ---------- Modo local (saludMonday caído / ids loc_*) ---------- + +describe('ruteo modo local (apartados)', () => { + function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } + } + + beforeEach(() => { + vi.clearAllMocks() + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + prismaMock.leadCaptura.findFirst.mockResolvedValue(null) + prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.cliente.findFirst.mockResolvedValue(null) + prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-local', ...data })) + prismaMock.documento.create.mockImplementation(async ({ data }) => ({ id: 'doc-1', ...data })) + prismaMock.syncPendiente.create.mockResolvedValue({}) + prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('cambiarEstadoApartadoEndpoint con id loc_*: fase local + CAMBIO_ETAPA encolado', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) // el id loc_* manda aunque Monday viva + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_apt', board: 'APARTADO' }) + const { res, json } = resFake() + + await cambiarEstadoApartadoEndpoint( + { params: { itemId: 'loc_apt' }, body: { estado: 'Alta Enkontrol' } } as unknown as Request, + res, + ) + + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_apt' }, + data: { etapa: 'Alta Enkontrol', pendienteSync: true }, + }) + expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ tipo: 'CAMBIO_ETAPA', referenciaLocal: 'loc_apt' }), + }) + expect(json).toHaveBeenCalledWith({ itemId: 'loc_apt', estado: 'Alta Enkontrol' }) + }) + + it('subirDocumentosApartado con Monday caído: nombre desde LeadCaptura local y checkbox encolado (CHECKBOX_DOC)', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + // El apartado local nació del "Aparto" de la oportunidad: su fila tiene el nombre + prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_apt', nombreLead: 'Luis Demo' }) + const archivo = { + originalname: 'curp.pdf', + path: '/tmp/uploads/apartados/loc_apt/CURP_1.pdf', + mimetype: 'application/pdf', + size: 1234, + } as Express.Multer.File + const { res, status } = resFake() + + await subirDocumentosApartado( + { params: { itemId: 'loc_apt' }, body: { tipo: 'CURP' }, files: [archivo], usuarioToken: { sub: 'u-1' } } as unknown as Request, + res, + ) + + // Cliente mínimo creado con el nombre de la fila local (no truena por obtenerNombreItem) + expect(prismaMock.cliente.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ nombreCompleto: 'Luis Demo', mondayApartadoId: 'loc_apt' }), + }) + expect(prismaMock.documento.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ clienteId: 'cli-local', tipo: 'CURP' }), + }) + // El checkbox no se intenta en Monday: queda encolado para el worker + expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tipo: 'CHECKBOX_DOC', + payloadJson: JSON.stringify({ checkbox: 'CURP', itemId: 'loc_apt' }), + referenciaLocal: 'loc_apt', + }), + }) + expect(status).toHaveBeenCalledWith(201) + }) +}) diff --git a/api-gateway/src/controllers/apartados.controller.ts b/api-gateway/src/controllers/apartados.controller.ts index dc719d6..943a42f 100644 --- a/api-gateway/src/controllers/apartados.controller.ts +++ b/api-gateway/src/controllers/apartados.controller.ts @@ -8,6 +8,9 @@ import { config } from '../config/index.js' import { mondayService, type MondayService } from '../services/monday.service.js' import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js' import { prisma } from '../services/prisma.js' +import { saludMonday } from '../services/salud-monday.service.js' +import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js' +import { esFalloMonday } from '../services/sync-monday.service.js' import { checkboxParaTipoDocumento } from './clientes.controller.js' /** @@ -54,7 +57,24 @@ export async function cambiarEstadoApartado( export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response): Promise { const body = cambiarEstadoSchema.parse(req.body) const itemId = String(req.params['itemId']) - await cambiarEstadoApartado(mondayService, itemId, body.estado) + // Modo local (id loc_* o Monday caído / fallback 502): fase en la fila local + // + CAMBIO_ETAPA encolado; ids Monday viejos sin fila crean sombra + if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { + await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', { + usuarioId: req.usuarioToken?.sub, + sombra: !esIdLocal(itemId), + }) + } else { + try { + await cambiarEstadoApartado(mondayService, itemId, body.estado) + } catch (err) { + if (!esFalloMonday(err)) throw err + await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', { + usuarioId: req.usuarioToken?.sub, + sombra: true, + }) + } + } res.json({ itemId, estado: body.estado }) } @@ -104,12 +124,28 @@ export const uploadDocumentosApartado = multer({ /** * Cliente local ligado al apartado de Monday; si aún no existe (apartado * creado sin pasar por el alta progresiva), se crea la ficha mínima para que - * los documentos tengan dónde vivir. + * los documentos tengan dónde vivir. El nombre se toma de Monday; si Monday + * está caído o el id es local (loc_*), se usa el de la fila LeadCaptura del + * apartado (board APARTADO, creada por el "Aparto" local) como fallback. */ async function clienteDeApartado(itemId: string, capturadoPorId?: string) { const existente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } }) if (existente) return existente - const nombre = await mondayService.obtenerNombreItem(itemId) + let nombre: string | null = null + if (!esIdLocal(itemId) && saludMonday.estaDisponible()) { + try { + nombre = await mondayService.obtenerNombreItem(itemId) + } catch (err) { + // eslint-disable-next-line no-console + console.error(`No se pudo leer el nombre del apartado ${itemId} en Monday; se usa el nombre local:`, err) + } + } + if (nombre === null) { + const captura = await prisma.leadCaptura.findFirst({ + where: { OR: [{ leadId: itemId }, { mondayItemId: itemId }] }, + }) + nombre = captura?.nombreLead ?? 'Cliente' + } return prisma.cliente.create({ data: { nombreCompleto: nombre, @@ -151,14 +187,20 @@ export async function subirDocumentosApartado(req: Request, res: Response): Prom ) // Prende el checkbox del documento en el item de Apartado (si el tipo tiene - // checkbox); un fallo de Monday no rompe la subida. + // checkbox); un fallo de Monday no rompe la subida: queda encolado + // (CHECKBOX_DOC) y el worker lo aplica al sincronizar. const checkbox = checkboxParaTipoDocumento(tipo) if (checkbox !== null) { - try { - await mondayService.cambiarValorColumna(config.monday.boards.apartado, itemId, checkbox, true) - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${itemId}:`, err) + if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { + await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId) + } else { + try { + await mondayService.cambiarValorColumna(config.monday.boards.apartado, itemId, checkbox, true) + } catch (err) { + // eslint-disable-next-line no-console + console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${itemId}:`, err) + await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId) + } } } res.status(201).json(documentos) diff --git a/api-gateway/src/controllers/clientes.controller.test.ts b/api-gateway/src/controllers/clientes.controller.test.ts index 63f2824..5b29c80 100644 --- a/api-gateway/src/controllers/clientes.controller.test.ts +++ b/api-gateway/src/controllers/clientes.controller.test.ts @@ -17,6 +17,9 @@ const prismaMock = vi.hoisted(() => ({ usuario: { findUnique: vi.fn(), }, + syncPendiente: { + create: vi.fn(), + }, })) const mondayMock = vi.hoisted(() => ({ @@ -276,3 +279,98 @@ describe('crearCliente — Apartado con ficha completa', () => { expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: undefined })) }) }) + +describe('réplica diferida (encolado cuando Monday falla)', () => { + const altaConVenta = { + datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6641112233' }, + viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-101', valorVenta: 1500000, enganche: 20000 }, + } + + const reqFake = (body: unknown): Request => ({ body } as Request) + const resFake = () => { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } + } + + function tiposEncolados(): string[] { + return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo) + } + + beforeEach(() => { + vi.clearAllMocks() + prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) + prismaMock.cliente.update.mockResolvedValue({}) + prismaMock.usuario.findUnique.mockResolvedValue(null) + prismaMock.syncPendiente.create.mockResolvedValue({}) + funnelMock.moverAApartoConDedup.mockResolvedValue('op-1') + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('falla crearContacto: 201 con ids null y CREAR_CONTACTO + CREAR_APARTADO encolados', async () => { + const { crearCliente } = await import('./clientes.controller.js') + mondayMock.crearContacto.mockRejectedValue(new Error('Monday caído')) + const { res, status, json } = resFake() + + await crearCliente(reqFake(altaConVenta), res) + + expect(status).toHaveBeenCalledWith(201) + expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: null }) + expect(tiposEncolados()).toEqual(['CREAR_CONTACTO', 'CREAR_APARTADO']) + // El payload del Contacto lleva los datos del alta (el worker no necesita nada más) + const llamadaContacto = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { + data: { payloadJson: string; referenciaLocal: string } + } + expect(JSON.parse(llamadaContacto.data.payloadJson)['datos']).toMatchObject({ + nombre: 'Cliente Demo', + estadoLabel: 'CLIENTE', + curp: 'CURP123', + }) + expect(llamadaContacto.data.referenciaLocal).toBe('cli-1') + // El del Apartado referencia al cliente para ligar mondayApartadoId al sincronizar + const llamadaApartado = prismaMock.syncPendiente.create.mock.calls[1]?.[0] as { + data: { payloadJson: string; referenciaLocal: string } + } + expect(JSON.parse(llamadaApartado.data.payloadJson)).toMatchObject({ + clienteId: 'cli-1', + datos: { clienteNombre: 'Cliente Demo', unidadCodigo: 'U-101', estadoLabel: 'Pago Apartado' }, + }) + }) + + it('falla solo el Apartado: conserva mondayContactoId (lo persiste) y encola CREAR_APARTADO', async () => { + const { crearCliente } = await import('./clientes.controller.js') + mondayMock.crearContacto.mockResolvedValue('cont-1') + mondayMock.crearApartado.mockRejectedValue(new Error('Monday caído')) + const { res, status, json } = resFake() + + await crearCliente(reqFake(altaConVenta), res) + + expect(status).toHaveBeenCalledWith(201) + expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayContactoId: 'cont-1', mondayApartadoId: null }) + expect(tiposEncolados()).toEqual(['CREAR_APARTADO']) // el Contacto ya existe: no se duplica + expect(prismaMock.cliente.update).toHaveBeenCalledWith({ + where: { id: 'cli-1' }, + data: { mondayContactoId: 'cont-1', mondayApartadoId: null }, + }) + }) + + it('falla moverAApartoConDedup: encola APARTO_OPORTUNIDAD con el leadMondayId', async () => { + const { crearCliente } = await import('./clientes.controller.js') + mondayMock.crearContacto.mockResolvedValue('cont-1') + mondayMock.crearApartado.mockResolvedValue('ap-1') + funnelMock.moverAApartoConDedup.mockRejectedValue(new Error('Monday caído')) + const { res, status } = resFake() + + await crearCliente(reqFake({ ...altaConVenta, leadMondayId: 'lead-9' }), res) + + expect(status).toHaveBeenCalledWith(201) + expect(tiposEncolados()).toEqual(['APARTO_OPORTUNIDAD']) + expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tipo: 'APARTO_OPORTUNIDAD', + payloadJson: JSON.stringify({ leadId: 'lead-9' }), + referenciaLocal: 'lead-9', + }), + }) + }) +}) diff --git a/api-gateway/src/controllers/clientes.controller.ts b/api-gateway/src/controllers/clientes.controller.ts index 1d533fa..45ee302 100644 --- a/api-gateway/src/controllers/clientes.controller.ts +++ b/api-gateway/src/controllers/clientes.controller.ts @@ -5,10 +5,13 @@ import { randomUUID } from 'node:crypto' import multer from 'multer' import { z } from 'zod' import { config } from '../config/index.js' -import { mondayService, type DatosContacto } from '../services/monday.service.js' +import { mondayService, type DatosApartado, type DatosContacto } from '../services/monday.service.js' import { funnelService } from '../services/funnel.service.js' import { prismaStateStore } from '../services/automatizaciones.service.js' import { prisma } from '../services/prisma.js' +import { encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js' +import { saludMonday } from '../services/salud-monday.service.js' +import { esFalloMonday } from '../services/sync-monday.service.js' import { NotFoundError, BadRequestError } from '../utils/errors.js' // ---------- Alta de clientes ---------- @@ -177,6 +180,8 @@ async function moverLeadAApartoSiAplica(body: Record): Promise< } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadMondayId}:`, err) + // El worker lo reintenta cuando Monday vuelva (con el mismo dedup) + await encolarSyncSeguro('APARTO_OPORTUNIDAD', { leadId: leadMondayId }, leadMondayId) } } } @@ -207,8 +212,12 @@ export function formaPagoLabel(tiposCredito: unknown): string | undefined { return undefined } -/** Item en el tablero Apartado (pipeline Enkontrol) con la ficha completa; solo si hay valor de venta */ -async function crearApartadoSiAplica( +/** + * Datos del item en el tablero Apartado (pipeline Enkontrol) para el alta; + * null si no hay valor de venta (no aplica apartado). Lo usan tanto la réplica + * directa (crearApartadoSiAplica) como el encolado diferido (CREAR_APARTADO). + */ +async function datosApartadoDeAlta( dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined }, vc: { ubicacion?: string | undefined @@ -217,7 +226,7 @@ async function crearApartadoSiAplica( tiposCredito?: unknown }, capturadoPorId: string | null, -): Promise { +): Promise { const valorVenta = aNumero(vc.valorVenta) if (valorVenta <= 0) return null const enganche = aNumero(vc.enganche) @@ -230,7 +239,7 @@ async function crearApartadoSiAplica( }) vendedorId = capturista?.mondayUserId ?? undefined } - return mondayService.crearApartado({ + return { clienteNombre: dg.nombreCompleto, unidadCodigo: vc.ubicacion ?? '', ...(enganche > 0 ? { montoApartado: enganche } : {}), @@ -245,7 +254,59 @@ async function crearApartadoSiAplica( fechaCompra: hoyMas(0), vendedorId, formaPagoLabel: formaPagoLabel(vc.tiposCredito), - }) + } +} + +/** Item en el tablero Apartado (pipeline Enkontrol) con la ficha completa; solo si hay valor de venta */ +async function crearApartadoSiAplica( + dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined }, + vc: { + ubicacion?: string | undefined + valorVenta?: string | number | undefined + enganche?: string | number | undefined + tiposCredito?: unknown + }, + capturadoPorId: string | null, +): Promise { + const datos = await datosApartadoDeAlta(dg, vc, capturadoPorId) + if (datos === null) return null + return mondayService.crearApartado(datos) +} + +/** + * Réplica diferida del alta (modo local): cuando la réplica Monday falla a + * media operación, encola en SyncPendiente solo lo que no alcanzó a crearse + * (Contacto y/o Apartado) para que el worker lo aplique cuando Monday vuelva. + * Si el alta venía ligada a un apartado Monday existente (apartadoMondayId), + * no se encola la creación: el item ya existe (solo falló enriquecerlo). + * Devuelve el mondayApartadoId efectivo tras el fallback. + */ +async function encolarReplicasPendientesCliente( + clienteId: string, + datosContacto: DatosContacto, + mondayContactoId: string | null, + mondayApartadoId: string | null, + dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined }, + vc: { + ubicacion?: string | undefined + valorVenta?: string | number | undefined + enganche?: string | number | undefined + tiposCredito?: unknown + }, + capturadoPorId: string | null, + apartadoMondayId: unknown, +): Promise { + if (mondayContactoId === null) { + await encolarSyncSeguro('CREAR_CONTACTO', { datos: datosContacto }, clienteId) + } + if (mondayApartadoId !== null) return mondayApartadoId + const existente = typeof apartadoMondayId === 'string' && apartadoMondayId !== '' ? apartadoMondayId : null + if (existente !== null) return existente + const datosApartado = await datosApartadoDeAlta(dg, vc, capturadoPorId) + if (datosApartado !== null) { + await encolarSyncSeguro('CREAR_APARTADO', { datos: datosApartado, clienteId }, clienteId) + } + return null } /** @@ -308,21 +369,22 @@ export async function crearCliente(req: Request, res: Response): Promise { // Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null let mondayContactoId: string | null = null let mondayApartadoId: string | null = null + const datosContacto: DatosContacto = { + nombre: dg.nombreCompleto, + estadoLabel: 'CLIENTE', + ...(dg.celular !== undefined ? { telefono: dg.celular } : {}), + ...(dg.email !== undefined ? { email: dg.email } : {}), + curp: dg.curp, + rfc: dg.rfc, + ...(dg.direccion !== undefined ? { direccion: dg.direccion } : {}), + // Perfil demográfico: solo lo que el formato de alta captura. + // Sin fuente en el formulario: Sexo (no se pregunta) y Actividad + // Economica distinta de 'Empleado' (INDEPENDIENTE es ambiguo). + ...perfilDemografico(dg), + ...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) }, + } try { - mondayContactoId = await mondayService.crearContacto({ - nombre: dg.nombreCompleto, - estadoLabel: 'CLIENTE', - ...(dg.celular !== undefined ? { telefono: dg.celular } : {}), - ...(dg.email !== undefined ? { email: dg.email } : {}), - curp: dg.curp, - rfc: dg.rfc, - ...(dg.direccion !== undefined ? { direccion: dg.direccion } : {}), - // Perfil demográfico: solo lo que el formato de alta captura. - // Sin fuente en el formulario: Sexo (no se pregunta) y Actividad - // Economica distinta de 'Empleado' (INDEPENDIENTE es ambiguo). - ...perfilDemografico(dg), - ...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) }, - }) + mondayContactoId = await mondayService.crearContacto(datosContacto) mondayApartadoId = await apartadoDeAlta(dg, vc, req.usuarioToken?.sub ?? null, body.apartadoMondayId) @@ -333,6 +395,28 @@ export async function crearCliente(req: Request, res: Response): Promise { } catch (err) { // eslint-disable-next-line no-console console.error('No se pudo replicar el cliente en Monday:', err) + // Encola la réplica diferida de lo que faltó y conserva los ids que sí se crearon + mondayApartadoId = await encolarReplicasPendientesCliente( + cliente.id, + datosContacto, + mondayContactoId, + mondayApartadoId, + dg, + vc, + req.usuarioToken?.sub ?? null, + body.apartadoMondayId, + ) + if (mondayContactoId !== null || mondayApartadoId !== null) { + try { + await prisma.cliente.update({ + where: { id: cliente.id }, + data: { mondayContactoId, mondayApartadoId }, + }) + } catch (err2) { + // eslint-disable-next-line no-console + console.error('No se pudieron guardar los ids Monday del cliente:', err2) + } + } } res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId }) @@ -440,17 +524,20 @@ export async function iniciarProgreso(req: Request, res: Response): Promise), + estadoLabel: 'PROSPECTO', + } try { - mondayContactoId = await mondayService.crearContacto({ - ...contactoDesdeDatos(body as Record), - estadoLabel: 'PROSPECTO', - }) + mondayContactoId = await mondayService.crearContacto(datosContactoProspecto) await prisma.cliente.update({ where: { id: cliente.id }, data: { mondayContactoId } }) } catch (err) { // eslint-disable-next-line no-console console.error('No se pudo crear el Contacto PROSPECTO en Monday:', err) + await encolarSyncSeguro('CREAR_CONTACTO', { datos: datosContactoProspecto }, cliente.id) } res.status(201).json({ id: cliente.id, mondayContactoId }) @@ -466,6 +553,7 @@ export async function actualizarProgreso(req: Request, res: Response): Promise), + estadoLabel: 'CLIENTE', + } try { if (mondayContactoId !== null) { - await mondayService.actualizarContacto(mondayContactoId, { - ...contactoDesdeDatos(body as Record), - estadoLabel: 'CLIENTE', - }) + await mondayService.actualizarContacto(mondayContactoId, datosContactoCliente) } else { - mondayContactoId = await mondayService.crearContacto({ - ...contactoDesdeDatos(body as Record), - estadoLabel: 'CLIENTE', - }) + mondayContactoId = await mondayService.crearContacto(datosContactoCliente) } if (mondayApartadoId === null) { @@ -540,6 +627,32 @@ export async function completarProgreso(req: Request, res: Response): Promise { const itemId = String(req.params['itemId']) - const apartado = (await mondayService.getApartados()).find((a) => a.id === itemId) - if (!apartado) throw new NotFoundError('Apartado no encontrado') - const tel = (apartado.telefono ?? '').replace(/\D/g, '').slice(-10) - const email = (apartado.email ?? '').trim().toLowerCase() - const coincide = (t: string, e: string): boolean => - (tel !== '' && t.replace(/\D/g, '').length >= 10 && t.replace(/\D/g, '').slice(-10) === tel) || - (email !== '' && e.trim().toLowerCase() === email) - - const [contactos, oportunidades] = await Promise.all([ - mondayService.getContactos(), - mondayService.getOportunidades(), - ]) - const contactoBase = contactos.find((c) => coincide(c.telefono, c.email)) - const contacto = contactoBase ? await mondayService.getContactoDetalle(contactoBase.id) : null - const oportunidad = oportunidades.find((o) => coincide(o.telefono, o.email)) ?? null - - // Cotización local más reciente: vinculada a la oportunidad o por teléfono - let cotizacion = null - if (oportunidad) { - cotizacion = await prisma.cotizacion.findFirst({ - where: { mondayItemId: oportunidad.id }, - orderBy: { createdAt: 'desc' }, - }) + // Monday caído (circuit breaker): prefill 100% local + if (!saludMonday.estaDisponible()) { + res.json(await prefillApartadoLocal(itemId)) + return } + + try { + const apartado = (await mondayService.getApartados()).find((a) => a.id === itemId) + if (!apartado) throw new NotFoundError('Apartado no encontrado') + + const tel = (apartado.telefono ?? '').replace(/\D/g, '').slice(-10) + const email = (apartado.email ?? '').trim().toLowerCase() + const coincide = (t: string, e: string): boolean => + (tel !== '' && t.replace(/\D/g, '').length >= 10 && t.replace(/\D/g, '').slice(-10) === tel) || + (email !== '' && e.trim().toLowerCase() === email) + + const [contactos, oportunidades] = await Promise.all([ + mondayService.getContactos(), + mondayService.getOportunidades(), + ]) + const contactoBase = contactos.find((c) => coincide(c.telefono, c.email)) + const contacto = contactoBase ? await mondayService.getContactoDetalle(contactoBase.id) : null + const oportunidad = oportunidades.find((o) => coincide(o.telefono, o.email)) ?? null + + // Cotización local más reciente: vinculada a la oportunidad o por teléfono + let cotizacion = null + if (oportunidad) { + cotizacion = await prisma.cotizacion.findFirst({ + where: { mondayItemId: oportunidad.id }, + orderBy: { createdAt: 'desc' }, + }) + } + if (!cotizacion && tel !== '') { + cotizacion = await prisma.cotizacion.findFirst({ + where: { clienteTelefono: { contains: tel } }, + orderBy: { createdAt: 'desc' }, + }) + } + + res.json({ + apartado, + contacto, + oportunidad, + cotizacion: cotizacion + ? { + tipoPropuesta: cotizacion.tipoPropuesta, + acabados: cotizacion.acabados, + apartado: cotizacion.apartado, + precioLista: cotizacion.precioLista, + precioFinal: cotizacion.precioFinal, + enganche: cotizacion.enganche, + numMensualidades: cotizacion.numMensualidades, + mensualidad: cotizacion.mensualidad, + pagoFinal: cotizacion.pagoFinal, + } + : null, + }) + } catch (err) { + // Monday se cayó a media lectura: prefill local (el 404 de "apartado no + // encontrado" NO es fallo de Monday y se propaga intacto) + if (!esFalloMonday(err)) throw err + res.json(await prefillApartadoLocal(itemId)) + } +} + +/** Shape de la cotización en la respuesta del prefill (compartido Monday/local) */ +function cotizacionPrefill(cotizacion: { + tipoPropuesta: string + acabados: boolean + apartado: number + precioLista: number + precioFinal: number + enganche: number + numMensualidades: number + mensualidad: number | null + pagoFinal: number +}) { + return { + tipoPropuesta: cotizacion.tipoPropuesta, + acabados: cotizacion.acabados, + apartado: cotizacion.apartado, + precioLista: cotizacion.precioLista, + precioFinal: cotizacion.precioFinal, + enganche: cotizacion.enganche, + numMensualidades: cotizacion.numMensualidades, + mensualidad: cotizacion.mensualidad, + pagoFinal: cotizacion.pagoFinal, + } +} + +/** + * Prefill local (Monday caído): el apartado es la fila LeadCaptura board= + * APARTADO (id loc_* o sombra); el contacto sale del Cliente ligado, la + * oportunidad de la captura board=OPORTUNIDADES con el mismo teléfono (su + * perfil va en datosJson) y la cotización de la tabla local. Mismo contrato + * que el camino Monday. 404 si no existe el apartado local. + */ +async function prefillApartadoLocal(itemId: string) { + const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) + if (!captura || captura.board !== 'APARTADO') throw new NotFoundError('Apartado no encontrado') + + const tel = (captura.telefono ?? '').replace(/\D/g, '').slice(-10) + const cliente = await prisma.cliente.findFirst({ + where: { + OR: [{ mondayApartadoId: itemId }, ...(tel !== '' ? [{ celular: { contains: tel } }] : [])], + }, + orderBy: { createdAt: 'desc' }, + }) + const oportunidadLocal = await prisma.leadCaptura.findFirst({ + where: { + board: 'OPORTUNIDADES', + ...(tel !== '' ? { telefono: { contains: tel } } : { nombreLead: captura.nombreLead }), + }, + orderBy: { updatedAt: 'desc' }, + }) + + // Perfil de la afluencia (datosJson): solo los campos que el front consume + let credito = '' + if (oportunidadLocal?.datosJson) { + try { + const datos = JSON.parse(oportunidadLocal.datosJson) as Record + if (typeof datos['credito'] === 'string') credito = datos['credito'] + } catch { + // datosJson inválido: se ignora, el prefill sale igual + } + } + + // Cotización local más reciente: vinculada a la oportunidad local o por teléfono + let cotizacion = oportunidadLocal + ? await prisma.cotizacion.findFirst({ + where: { mondayItemId: oportunidadLocal.leadId }, + orderBy: { createdAt: 'desc' }, + }) + : null if (!cotizacion && tel !== '') { cotizacion = await prisma.cotizacion.findFirst({ where: { clienteTelefono: { contains: tel } }, @@ -686,22 +915,36 @@ export async function prefillApartadoEndpoint(req: Request, res: Response): Prom }) } - res.json({ - apartado, - contacto, - oportunidad, - cotizacion: cotizacion + return { + apartado: { + id: captura.leadId, + nombre: captura.nombreLead, + unidadCodigo: cliente?.unidadCodigo ?? cotizacion?.unidadCodigo ?? '', + fechaLimite: '', + estado: captura.etapa, + vendedorIds: [] as number[], + telefono: captura.telefono ?? '', + email: captura.email ?? '', + precioFinal: cotizacion?.precioFinal ?? 0, + enganche: cotizacion?.enganche ?? 0, + }, + contacto: cliente ? { - tipoPropuesta: cotizacion.tipoPropuesta, - acabados: cotizacion.acabados, - apartado: cotizacion.apartado, - precioLista: cotizacion.precioLista, - precioFinal: cotizacion.precioFinal, - enganche: cotizacion.enganche, - numMensualidades: cotizacion.numMensualidades, - mensualidad: cotizacion.mensualidad, - pagoFinal: cotizacion.pagoFinal, + nombre: cliente.nombreCompleto, + Teléfono: cliente.celular ?? '', + Email: cliente.email ?? '', + CURP: cliente.curp, + RFC: cliente.rfc, } : null, - }) + oportunidad: oportunidadLocal + ? { + id: oportunidadLocal.leadId, + desarrollo: oportunidadLocal.desarrollo ?? '', + modeloInteres: oportunidadLocal.modelo ?? '', + credito, + } + : null, + cotizacion: cotizacion ? cotizacionPrefill(cotizacion) : null, + } } diff --git a/api-gateway/src/controllers/contactos-fallback.test.ts b/api-gateway/src/controllers/contactos-fallback.test.ts new file mode 100644 index 0000000..b64eb91 --- /dev/null +++ b/api-gateway/src/controllers/contactos-fallback.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' +import { AppError, NotFoundError } from '../utils/errors.js' + +// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ---------- + +const prismaMock = vi.hoisted(() => ({ + cliente: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + leadCaptura: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + cotizacion: { + findMany: vi.fn(), + }, + documento: { + findMany: vi.fn(), + }, + usuario: { + findMany: vi.fn(), + }, +})) + +const mondayMock = vi.hoisted(() => ({ + getContactos: vi.fn(), + getContactoDetalle: vi.fn(), + getUsuariosWorkspace: vi.fn(), +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) +vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) + +import { detalleContactoEndpoint, listarContactosEndpoint } from './contactos.controller.js' +import { saludMonday } from '../services/salud-monday.service.js' + +function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } +} + +const capturaLocal = { + id: 'row-1', + leadId: 'loc_1', + nombreLead: 'ANA DEMO', + telefono: '6649998877', + email: null, + origen: 'WhatsApp', + desarrollo: 'SENDA', + modelo: '1R', + board: 'OPORTUNIDADES', + etapa: 'Afluencia', + motivo: null, + submotivo: null, + datosJson: null, + mondayItemId: null, + pendienteSync: true, + usuarioId: 'u-1', + createdAt: new Date('2026-09-02T10:00:00'), + updatedAt: new Date('2026-09-03T10:00:00'), +} + +beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible') + saludMonday.restablecer() + prismaMock.cliente.findFirst.mockResolvedValue(null) + prismaMock.cliente.findMany.mockResolvedValue([]) + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + prismaMock.leadCaptura.findMany.mockResolvedValue([capturaLocal]) + prismaMock.cotizacion.findMany.mockResolvedValue([]) + prismaMock.documento.findMany.mockResolvedValue([]) + prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', nombre: 'Martin Zavala' }]) + mondayMock.getUsuariosWorkspace.mockResolvedValue([]) +}) + +describe('listarContactosEndpoint (fallback local)', () => { + it('Monday caído (circuit breaker): lista local sin llamar a Monday', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + const { res, json } = resFake() + + await listarContactosEndpoint({} as Request, res) + + expect(mondayMock.getContactos).not.toHaveBeenCalled() + const lista = json.mock.calls[0]?.[0] as Record[] + expect(lista[0]).toMatchObject({ id: 'loc_1', nombre: 'ANA DEMO', estado: 'Afluencia', fuente: 'local' }) + }) + + it('Monday lanza 502: fallback local', async () => { + mondayMock.getContactos.mockRejectedValue(new AppError(502, 'Monday no respondió')) + const { res, json } = resFake() + + await listarContactosEndpoint({} as Request, res) + + const lista = json.mock.calls[0]?.[0] as Record[] + expect(lista[0]).toMatchObject({ id: 'loc_1', fuente: 'local' }) + }) +}) + +describe('detalleContactoEndpoint (fallback local)', () => { + it('Monday caído: detalle 100% local con la misma forma', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal) + const { res, json } = resFake() + + await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res) + + expect(mondayMock.getContactoDetalle).not.toHaveBeenCalled() + const detalle = json.mock.calls[0]?.[0] as Record + expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877' }) + expect(detalle['propiedad']).toBeNull() + expect((detalle['relaciones'] as Record)['oportunidades']).toHaveLength(1) + expect(Array.isArray(detalle['historial'])).toBe(true) + }) + + it('Monday caído y el id no existe en local → 404', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + await expect( + detalleContactoEndpoint({ params: { id: 'nope' } } as unknown as Request, resFake().res), + ).rejects.toThrow(NotFoundError) + }) + + it('Monday lanza 502: cae al detalle local; el 404 de Monday se propaga intacto', async () => { + mondayMock.getContactoDetalle.mockRejectedValue(new AppError(502, 'Monday no respondió')) + prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal) + const { res, json } = resFake() + await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res) + expect(json).toHaveBeenCalledOnce() + + mondayMock.getContactoDetalle.mockRejectedValue(new NotFoundError('Contacto no encontrado')) + await expect( + detalleContactoEndpoint({ params: { id: 'x' } } as unknown as Request, resFake().res), + ).rejects.toThrow(NotFoundError) + }) +}) diff --git a/api-gateway/src/controllers/contactos.controller.ts b/api-gateway/src/controllers/contactos.controller.ts index 7b8faa8..7bb3223 100644 --- a/api-gateway/src/controllers/contactos.controller.ts +++ b/api-gateway/src/controllers/contactos.controller.ts @@ -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 { - 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 { 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( - 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( + 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) + } } diff --git a/api-gateway/src/controllers/cotizaciones-fallback.test.ts b/api-gateway/src/controllers/cotizaciones-fallback.test.ts new file mode 100644 index 0000000..f6e4ff5 --- /dev/null +++ b/api-gateway/src/controllers/cotizaciones-fallback.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' +import { AppError, NotFoundError } from '../utils/errors.js' + +// ---------- Prisma, Monday y Funnel mockeados (los tests no tocan BD ni red) ---------- + +const prismaMock = vi.hoisted(() => ({ + unidad: { + findUnique: vi.fn(), + }, + cotizacion: { + create: vi.fn(), + update: vi.fn(), + }, + syncPendiente: { + create: vi.fn(), + }, +})) + +const mondayMock = vi.hoisted(() => ({ + getUnidad: vi.fn(), + crearOportunidad: vi.fn(), + actualizarOportunidadCotizacion: vi.fn(), +})) + +const funnelMock = vi.hoisted(() => ({ + oportunidadPorTelefono: vi.fn(), +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) +vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) +vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock })) + +import { crearCotizacion } from './cotizaciones.controller.js' +import { saludMonday } from '../services/salud-monday.service.js' + +/** + * Fallback del getUnidad obligatorio al guardar cotización: con Monday caído + * (o 502) la unidad sale del espejo local; si no existe en ninguno → 404. + */ + +const filaUnidad = { + codigo: 'U-101', + desarrollo: 'DIAGONAL', + modelo: '2RA', + supConstruccion: 60, + supExcedente: 0, + supEstacionamiento: 0, + precioLista: 1_000_000, + precioAcabados: 1_100_000, + status: 'DISPONIBLE', + etapa: '', +} + +const bodyBase = { + unidadCodigo: 'U-101', + acabados: false, + clienteNombre: 'Cliente Demo', + clienteTelefono: '4771234567', + tipoPropuesta: 'BANCARIO' as const, +} + +function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible') + saludMonday.restablecer() + prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad) + prismaMock.cotizacion.create.mockImplementation(async ({ data }) => ({ id: 'cot-1', ...data })) + prismaMock.cotizacion.update.mockResolvedValue({}) + prismaMock.syncPendiente.create.mockResolvedValue({}) + mondayMock.crearOportunidad.mockResolvedValue('monday-op-1') + mondayMock.actualizarOportunidadCotizacion.mockResolvedValue(undefined) + funnelMock.oportunidadPorTelefono.mockResolvedValue(null) +}) + +describe('crearCotizacion (getUnidad con fallback local)', () => { + it('Monday caído: cotiza con la unidad del espejo local (sin llamar a getUnidad)', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + const { res, status, json } = resFake() + + await crearCotizacion({ body: bodyBase } as Request, res) + + expect(mondayMock.getUnidad).not.toHaveBeenCalled() + expect(prismaMock.unidad.findUnique).toHaveBeenCalledWith({ where: { codigo: 'U-101' } }) + expect(status).toHaveBeenCalledWith(201) + const respuesta = json.mock.calls[0]?.[0] as Record + expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL' }) + expect(respuesta['propuesta']).toBeDefined() + }) + + it('Monday lanza 502 en getUnidad: mismo fallback local', async () => { + mondayMock.getUnidad.mockRejectedValue(new AppError(502, 'Monday no respondió')) + const { res, status } = resFake() + + await crearCotizacion({ body: bodyBase } as Request, res) + + expect(status).toHaveBeenCalledWith(201) + }) + + it('la unidad no existe ni en Monday ni en local → 404 como hoy', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + prismaMock.unidad.findUnique.mockResolvedValue(null) + const { res } = resFake() + + await expect(crearCotizacion({ body: bodyBase } as Request, res)).rejects.toThrow(NotFoundError) + expect(prismaMock.cotizacion.create).not.toHaveBeenCalled() + }) +}) diff --git a/api-gateway/src/controllers/cotizaciones.controller.test.ts b/api-gateway/src/controllers/cotizaciones.controller.test.ts index 1cab40e..a9018f3 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.test.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.test.ts @@ -8,6 +8,9 @@ const prismaMock = vi.hoisted(() => ({ create: vi.fn(), update: vi.fn(), }, + syncPendiente: { + create: vi.fn(), + }, })) const mondayMock = vi.hoisted(() => ({ @@ -103,13 +106,38 @@ describe('crearCotizacion (POST /api/cotizaciones) — match con el funnel', () expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce() }) - it('si Monday falla, la cotización igual se crea (201 con mondayItemId null)', async () => { + it('si Monday falla, la cotización igual se crea (201 con mondayItemId null) y la vinculación queda encolada', async () => { mondayMock.crearOportunidad.mockRejectedValue(new Error('Monday caído')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + prismaMock.syncPendiente.create.mockResolvedValue({}) const { res, status, json } = resFake() await crearCotizacion(reqFake(bodyBase), res) expect(status).toHaveBeenCalledWith(201) expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: null }) expect(prismaMock.cotizacion.create).toHaveBeenCalledOnce() + // COTIZACION_OPORTUNIDAD con los datos completos + teléfono para el match del worker + expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tipo: 'COTIZACION_OPORTUNIDAD', + referenciaLocal: 'cot-1', + }), + }) + const llamada = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { data: { payloadJson: string } } + expect(JSON.parse(llamada.data.payloadJson)).toMatchObject({ + clienteTelefono: '4771234567', + datos: { unidadCodigo: 'U-101', estadoLabel: 'Cotizador' }, + }) + }) + + it('si falla la actualización con oportunidadId explícito, se encola con ESE id', async () => { + mondayMock.actualizarOportunidadCotizacion.mockRejectedValue(new Error('Monday caído')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + prismaMock.syncPendiente.create.mockResolvedValue({}) + const { res, status } = resFake() + await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res) + expect(status).toHaveBeenCalledWith(201) + const llamada = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { data: { payloadJson: string } } + expect(JSON.parse(llamada.data.payloadJson)).toMatchObject({ oportunidadId: 'opp-midia-1' }) }) it('con oportunidadId explícito (botón "Cotizar" de Mi día): actualiza ESE item y no consulta el funnel', async () => { diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts index b97b7c2..0ce6573 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.ts @@ -1,6 +1,7 @@ import type { Request, Response } from 'express' import { z } from 'zod' import { mondayService, type DatosOportunidad } from '../services/monday.service.js' +import { obtenerUnidadConFallback } from '../services/unidades-local.service.js' import { funnelService } from '../services/funnel.service.js' import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js' import { @@ -8,6 +9,7 @@ import { type ResultadoAlertas, } from '../services/alertas-inventario.service.js' import { prisma } from '../services/prisma.js' +import { encolarSyncSeguro } from '../services/crm-local.service.js' import { NotFoundError } from '../utils/errors.js' import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js' @@ -50,7 +52,8 @@ export const LABEL_PROPUESTA_MONDAY: Record = { export async function crearCotizacion(req: Request, res: Response): Promise { const body = crearCotizacionSchema.parse(req.body) - const unidad = await mondayService.getUnidad(body.unidadCodigo) + // Monday caído (o 502) → espejo local (tabla Unidad); 404 solo si no existe en ninguno + const unidad = await obtenerUnidadConFallback(body.unidadCodigo) if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`) const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT @@ -78,21 +81,23 @@ export async function crearCotizacion(req: Request, res: Response): Promise ({ leadCaptura: { create: vi.fn(), + findFirst: vi.fn(), + findUnique: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), }, leadOportunidad: { create: vi.fn(), @@ -32,6 +41,13 @@ const prismaMock = vi.hoisted(() => ({ findUnique: vi.fn(), upsert: vi.fn(), }, + syncPendiente: { + create: vi.fn(), + }, + cliente: { + findMany: vi.fn(), + }, + $transaction: vi.fn(), })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) @@ -787,3 +803,217 @@ describe('convertirLead (POST /api/leads/:itemId/convertir) — botón "Agendo" expect(monday.oportunidadesCreadas[0]?.['origenLabel']).toBeUndefined() }) }) + +// ---------- Modo local (saludMonday caído / ids loc_* / fallback 502) ---------- + +describe('ruteo modo local (Monday caído o fallback 502)', () => { + function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } + } + + const reqCon = (overrides: Record): Request => + ({ + body: {}, + params: {}, + usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' }, + ...overrides, + }) as unknown as Request + + function tiposEncolados(): string[] { + return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo) + } + + beforeEach(() => { + vi.clearAllMocks() + prismaMock.leadCaptura.findFirst.mockResolvedValue(null) // sin duplicados locales + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + prismaMock.leadCaptura.findMany.mockResolvedValue([]) + prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.leadOportunidad.create.mockResolvedValue({}) + prismaMock.syncPendiente.create.mockResolvedValue({}) + prismaMock.cliente.findMany.mockResolvedValue([]) + prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('crearLeadEndpoint con Monday caído: captura local 201 (id loc_*) y encola CREAR_LEAD sin tocar Monday', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + const spyCrear = vi.spyOn(mondayService, 'crearLead') + const { res, status, json } = resFake() + + await crearLeadEndpoint(reqCon({ body: { nombre: 'Lead Local', telefono: '4771234567' } }), res) + + expect(spyCrear).not.toHaveBeenCalled() + expect(status).toHaveBeenCalledWith(201) + const cuerpo = json.mock.calls[0]?.[0] as { id: string; asignacion: string } + expect(cuerpo.id).toMatch(/^loc_/) + expect(cuerpo).toMatchObject({ estado: 'NUEVO', oportunidadId: null, asignacion: 'capturista' }) + expect(tiposEncolados()).toEqual(['CREAR_LEAD', 'ASIGNAR_PERSONA']) + // La fila local quedó board=LEADS etapa NUEVO pendiente de sincronizar + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }), + }) + }) + + it('crearLeadEndpoint con Monday vivo que lanza 502: fallback a captura local (misma forma de respuesta)', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) + vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockRejectedValue(new AppError(502, 'Monday no respondió')) + const { res, status, json } = resFake() + + await crearLeadEndpoint(reqCon({ body: { nombre: 'Lead Fallback', telefono: '4771234567' } }), res) + + expect(status).toHaveBeenCalledWith(201) + expect((json.mock.calls[0]?.[0] as { id: string }).id).toMatch(/^loc_/) + expect(tiposEncolados()).toContain('CREAR_LEAD') + }) + + it('crearLeadEndpoint con error NO-502 de Monday: se propaga (no hay fallback)', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) + vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockRejectedValue(new AppError(400, 'Origen inválido')) + await expect( + crearLeadEndpoint(reqCon({ body: { nombre: 'X', telefono: '4771234567' } }), resFake().res), + ).rejects.toMatchObject({ status: 400 }) + }) + + it('crearAfluenciaEndpoint con Monday caído: afluencia local 201 y encola CREAR_AFLUENCIA', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + const spyCrear = vi.spyOn(mondayService, 'crearOportunidadAfluencia') + const { res, status, json } = resFake() + + await crearAfluenciaEndpoint(reqCon({ body: { nombre: 'Afluencia Local', telefono: '6641112233', modelo: 'SOHO' } }), res) + + expect(spyCrear).not.toHaveBeenCalled() + expect(status).toHaveBeenCalledWith(201) + const cuerpo = json.mock.calls[0]?.[0] as { id: string; oportunidadId: string } + expect(cuerpo.id).toMatch(/^loc_/) + expect(cuerpo.oportunidadId).toBe(cuerpo.id) + expect(tiposEncolados()).toEqual(['CREAR_AFLUENCIA', 'ASIGNAR_PERSONA']) + // Auto-mapping local leadId = oportunidadId + expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ + data: { leadId: cuerpo.id, oportunidadId: cuerpo.id }, + }) + }) + + it('cambiarEstadoLeadEndpoint con id loc_* siempre va a local (aunque Monday esté vivo)', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) + const spyCambiar = vi.spyOn(mondayService, 'cambiarValorColumna') + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'LEADS' }) + const { res, json } = resFake() + + await cambiarEstadoLeadEndpoint( + reqCon({ params: { itemId: 'loc_1' }, body: { estado: 'CONTACTADO', fuente: 'lead' } }), + res, + ) + + expect(spyCambiar).not.toHaveBeenCalled() + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_1' }, + data: { etapa: 'Contactado', pendienteSync: true }, + }) + expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA']) + expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', estado: 'CONTACTADO' }) + }) + + it('cambiarEstadoLeadEndpoint con id Monday y 502: crea fila sombra y encola CAMBIO_ETAPA', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) + vi.spyOn(mondayService, 'cambiarValorColumna').mockRejectedValue(new AppError(502, 'Monday no respondió')) + const { res, json } = resFake() + + await cambiarEstadoLeadEndpoint( + reqCon({ params: { itemId: '99887766' }, body: { estado: 'CONTACTADO', fuente: 'lead' } }), + res, + ) + + // Sombra: el item Monday viejo queda reflejado localmente (Mi día) con su id como mondayItemId + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ leadId: '99887766', board: 'LEADS', mondayItemId: '99887766' }), + }) + expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA']) + expect(json).toHaveBeenCalledWith({ itemId: '99887766', estado: 'CONTACTADO' }) + }) + + it('descartarLeadEndpoint con id loc_*: descarte local, motivoItemId null y DESCARTE encolado', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_2', nombreLead: 'Lead Local' }) + const { res, json } = resFake() + + await descartarLeadEndpoint( + reqCon({ params: { itemId: 'loc_2' }, body: { motivo: 'Precio', subcategoria: 'Enganche' } }), + res, + ) + + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_2' }, + data: expect.objectContaining({ etapa: 'Perdido', motivo: 'Precio', submotivo: 'Enganche' }), + }) + expect(tiposEncolados()).toEqual(['DESCARTE']) + expect(json).toHaveBeenCalledWith({ itemId: 'loc_2', estado: 'Perdido', motivo: 'Precio', motivoItemId: null }) + }) + + it('convertirLeadEndpoint con id loc_*: conversión local 201 y CONVERSION encolada', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) + prismaMock.leadCaptura.findUnique.mockResolvedValue({ + leadId: 'loc_lead', + oportunidadId: null, + usuarioId: 'u-1', + nombreLead: 'Lead Local', + telefono: '4771234567', + email: null, + origen: null, + desarrollo: null, + modelo: null, + datosJson: null, + }) + const { res, status, json } = resFake() + + await convertirLeadEndpoint(reqCon({ params: { itemId: 'loc_lead' }, body: {} }), res) + + expect(status).toHaveBeenCalledWith(201) + const cuerpo = json.mock.calls[0]?.[0] as { leadId: string; oportunidadId: string; etapa: string } + expect(cuerpo.leadId).toBe('loc_lead') + expect(cuerpo.oportunidadId).toMatch(/^loc_/) + expect(cuerpo.etapa).toBe('Agendo Cita') + expect(tiposEncolados()).toEqual(['CONVERSION']) + }) + + it('convertirLeadEndpoint local con lead ya convertido → 409', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_lead', oportunidadId: 'loc_op_previa' }) + await expect( + convertirLeadEndpoint(reqCon({ params: { itemId: 'loc_lead' }, body: {} }), resFake().res), + ).rejects.toMatchObject({ status: 409 }) + }) + + it('buscarLeadsEndpoint con Monday caído busca en local (LeadCaptura + Cliente)', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + const spyLeads = vi.spyOn(mondayService, 'getLeads') + prismaMock.leadCaptura.findMany.mockResolvedValue([ + { + leadId: 'loc_1', + nombreLead: 'Ana Local', + telefono: '4771234567', + email: null, + etapa: 'Afluencia', + origen: 'WhatsApp', + board: 'OPORTUNIDADES', + desarrollo: 'SENDA', + modelo: null, + updatedAt: new Date('2026-09-05T10:00:00Z'), + }, + ]) + const { res, json } = resFake() + + await buscarLeadsEndpoint(reqCon({ query: { q: 'ana' } }), res) + + expect(spyLeads).not.toHaveBeenCalled() + expect(json).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'loc_1', nombre: 'Ana Local', fuente: 'oportunidad', estado: 'Afluencia' }), + ]) + }) +}) diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index b11aa7d..7a533a0 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -5,6 +5,9 @@ import { mondayService, type LeadMonday, type MondayService, type OportunidadMon import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js' import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js' import { prisma } from '../services/prisma.js' +import { saludMonday } from '../services/salud-monday.service.js' +import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js' +import { esFalloMonday } from '../services/sync-monday.service.js' import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js' import { AppError } from '../utils/errors.js' import { NotFoundError } from '../utils/errors.js' @@ -402,7 +405,8 @@ export async function descartarLead( /** * Self-assign del capturista con bandera anti-carrera rr:skip (para que el * webhook create_item no lo reparta por round-robin mientras Monday propaga la - * escritura; si la asignación falla, se apaga y cae al reparto). + * escritura; si la asignación falla, se apaga y cae al reparto — y queda + * encolada como ASIGNAR_PERSONA para reintentarla cuando Monday vuelva). * Devuelve cómo quedó la asignación. */ async function asignarCapturistaSiAplica( @@ -424,6 +428,8 @@ async function asignarCapturistaSiAplica( return 'capturista' } catch (err) { await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'round-robin') + // Best-effort: el worker la reintenta al drenar la cola + await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: capturista.mondayUserId }, itemId) throw err } } @@ -437,7 +443,23 @@ async function asignarCapturistaSiAplica( /** POST /api/leads { nombre, telefono?, email?, origen?, desarrollo?, notas? } — captura rápida (tablero Leads) */ export async function crearLeadEndpoint(req: Request, res: Response): Promise { const body = crearLeadSchema.parse(req.body) - const resultado = await crearLead(mondayService, body) + const usuarioSub = req.usuarioToken?.sub + // Modo local: Monday caído (flag) → directo a crmLocal; Monday vivo → flujo + // actual con fallback a local si la llamada lanza 502. La respuesta mantiene + // la misma forma en ambos caminos (el front no se entera). + let modoLocal = !saludMonday.estaDisponible() + let resultado: ResultadoCrearLead + if (modoLocal) { + resultado = await crmLocal.crearLead(body, usuarioSub) + } else { + try { + resultado = await crearLead(mondayService, body) + } catch (err) { + if (!esFalloMonday(err)) throw err + modoLocal = true + resultado = await crmLocal.crearLead(body, usuarioSub) + } + } if ('duplicado' in resultado) { res.status(409).json({ error: 'Ya existe un lead con ese teléfono', @@ -446,16 +468,27 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise { const body = crearAfluenciaSchema.parse(req.body) - const resultado = await crearAfluencia(mondayService, body) + const usuarioSub = req.usuarioToken?.sub + // Modo local: Monday caído (flag) → crmLocal (crear o actualizar); fallback + // a local tras 502. Misma forma de respuesta en ambos caminos. + let modoLocal = !saludMonday.estaDisponible() + let resultado: ResultadoCrearLead + if (modoLocal) { + resultado = await crmLocal.crearAfluencia(body, usuarioSub) + } else { + try { + resultado = await crearAfluencia(mondayService, body) + } catch (err) { + if (!esFalloMonday(err)) throw err + modoLocal = true + resultado = await crmLocal.crearAfluencia(body, usuarioSub) + } + } if ('duplicado' in resultado) { res.status(409).json({ error: 'Ya existe un lead con ese teléfono', @@ -487,16 +535,25 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true }) return } + if (modoLocal) { + res.status(201).json({ + id: resultado.id, + estado: 'Afluencia', + oportunidadId: resultado.oportunidadId, + asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin', + }) + return + } // Registro local del capturista para "Mis capturas" del panel Mi día. // leadId = el id de la oportunidad (la afluencia ES la oportunidad). Un // fallo aquí no rompe la respuesta: la afluencia ya quedó creada en Monday. - if (req.usuarioToken?.sub) { + if (usuarioSub) { try { await prisma.leadCaptura.create({ data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId, - usuarioId: req.usuarioToken.sub, + usuarioId: usuarioSub, nombreLead: body.nombre, telefono: body.telefono ?? null, }, @@ -517,15 +574,51 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi // eslint-disable-next-line no-console console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err) } - const asignacion = await asignarCapturistaSiAplica(req.usuarioToken?.sub, resultado.id, body.nombre, 'oportunidades') + const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'oportunidades') res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion }) } +/** + * Cambio de estado en modo local: guarda la etapa (label real del board) en la + * fila local y encola CAMBIO_ETAPA. Con id Monday sin fila local (fallback 502 + * sobre items viejos) crea la fila "sombra" para que Mi día lo refleje. + */ +async function cambiarEstadoLocal( + itemId: string, + estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number], + fecha: string | undefined, + fuente: FuenteLead, + usuarioId: string | undefined, +): Promise { + const esLead = fuente === 'lead' + if (estado === 'INTERESADO' && !esLead) { + throw new AppError(400, 'La etapa Interesado solo aplica a leads del tablero Leads') + } + const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado] + await crmLocal.cambiarEstado(itemId, label, esLead ? 'LEADS' : 'OPORTUNIDADES', { + fecha, + usuarioId, + sombra: !esIdLocal(itemId), + }) +} + /** POST /api/leads/:itemId/estado { estado, fecha?, fuente? } */ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise { const body = cambiarEstadoSchema.parse(req.body) const itemId = String(req.params['itemId']) - await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente) + const fuente = body.fuente ?? 'oportunidad' + // Los ids loc_* siempre van al path local (aún no existen en Monday); con el + // flag caído todo va a local; con Monday vivo, fallback a local tras 502. + if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { + await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub) + } else { + try { + await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente) + } catch (err) { + if (!esFalloMonday(err)) throw err + await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub) + } + } res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) }) } @@ -533,7 +626,30 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr export async function descartarLeadEndpoint(req: Request, res: Response): Promise { const body = descartarSchema.parse(req.body) const itemId = String(req.params['itemId']) - const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria) + const fuente = body.fuente ?? 'oportunidad' + let motivoItemId: string | null = null + if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { + await crmLocal.descartar(itemId, body.motivo, { + fuente, + subcategoria: body.subcategoria, + comentario: body.comentario, + usuarioId: req.usuarioToken?.sub, + sombra: !esIdLocal(itemId), + }) + } else { + try { + motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria) + } catch (err) { + if (!esFalloMonday(err)) throw err + await crmLocal.descartar(itemId, body.motivo, { + fuente, + subcategoria: body.subcategoria, + comentario: body.comentario, + usuarioId: req.usuarioToken?.sub, + sombra: !esIdLocal(itemId), + }) + } + } res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId }) } @@ -618,18 +734,39 @@ export async function convertirLead( export async function convertirLeadEndpoint(req: Request, res: Response): Promise { const body = convertirSchema.parse(req.body ?? {}) const itemId = String(req.params['itemId']) - const { oportunidadId } = await convertirLead(mondayService, itemId, req.usuarioToken?.sub, body.fecha) - res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' }) + const usuarioSub = req.usuarioToken?.sub + // Modo local (id loc_* o Monday caído / fallback 502): la conversión crea la + // oportunidad local en 'Agendo Cita' y encola CONVERSION. Nota: en el + // fallback la fecha de la cita viaja en el CAMBIO_ETAPA posterior del front, + // como en el flujo normal. + if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { + const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub) + res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' }) + return + } + try { + const { oportunidadId } = await convertirLead(mondayService, itemId, usuarioSub, body.fecha) + res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' }) + } catch (err) { + if (!esFalloMonday(err)) throw err + const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub) + res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' }) + } } /** * GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de * clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2 * caracteres; devuelve máximo 15, cada uno con su `fuente`. + * Con Monday caído busca en lo local (LeadCaptura + Cliente), misma forma. */ export async function buscarLeadsEndpoint(req: Request, res: Response): Promise { const q = String(req.query['q'] ?? '').trim() if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres') + if (!saludMonday.estaDisponible()) { + res.json(await crmLocal.buscarLocales(q)) + return + } const [leads, oportunidades] = await Promise.all([mondayService.getLeads(), mondayService.getOportunidades()]) res.json(buscarLeads(leads, oportunidades, q)) } diff --git a/api-gateway/src/controllers/mi-dia.controller.test.ts b/api-gateway/src/controllers/mi-dia.controller.test.ts new file mode 100644 index 0000000..549705a --- /dev/null +++ b/api-gateway/src/controllers/mi-dia.controller.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' +import { AppError } from '../utils/errors.js' + +// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ---------- + +const prismaMock = vi.hoisted(() => ({ + usuario: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + leadCaptura: { + findMany: vi.fn(), + }, + leadOportunidad: { + findMany: vi.fn(), + }, + cliente: { + findMany: vi.fn(), + }, +})) + +const mondayMock = vi.hoisted(() => ({ + getUsuariosWorkspace: vi.fn(), + getLeads: vi.fn(), + getOportunidades: vi.fn(), + getActividades: vi.fn(), + getApartados: vi.fn(), + getUltimosCambios: vi.fn(), +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) +vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) + +import { resumenMiDiaEndpoint } from './mi-dia.controller.js' +import { saludMonday } from '../services/salud-monday.service.js' + +const usuarioFake = { + id: 'u-1', + nombre: 'Martin Zavala', + rol: 'vendedor', + activo: true, + mondayUserId: 123, +} + +function capturaFake(parcial: Record & { leadId: string }) { + return { + id: `row-${parcial.leadId}`, + nombreLead: `Persona ${parcial.leadId}`, + telefono: null, + email: null, + origen: null, + desarrollo: null, + modelo: null, + board: 'LEADS', + etapa: 'NUEVO', + motivo: null, + submotivo: null, + datosJson: null, + mondayItemId: null, + pendienteSync: false, + usuarioId: 'u-1', + createdAt: new Date('2026-09-03T10:00:00'), + updatedAt: new Date('2026-09-04T10:00:00'), + ...parcial, + } +} + +function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } +} + +const reqFake = { usuarioToken: { sub: 'u-1' } } as unknown as Request + +beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible') + saludMonday.restablecer() + prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake) + prismaMock.usuario.findMany.mockResolvedValue([]) + prismaMock.leadCaptura.findMany.mockResolvedValue([]) + prismaMock.leadOportunidad.findMany.mockResolvedValue([]) + prismaMock.cliente.findMany.mockResolvedValue([]) + mondayMock.getUsuariosWorkspace.mockResolvedValue([]) + mondayMock.getLeads.mockResolvedValue([]) + mondayMock.getOportunidades.mockResolvedValue([]) + mondayMock.getActividades.mockResolvedValue([]) + mondayMock.getApartados.mockResolvedValue([]) + mondayMock.getUltimosCambios.mockResolvedValue(new Map()) +}) + +describe('resumenMiDiaEndpoint (modo local y unión)', () => { + it('Monday caído (circuit breaker): resumen 100% local, sin llamadas a Monday', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + prismaMock.leadCaptura.findMany.mockResolvedValue([ + capturaFake({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }), + capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', pendienteSync: true }), + ]) + const { res, json } = resFake() + + await resumenMiDiaEndpoint(reqFake, res) + + expect(mondayMock.getLeads).not.toHaveBeenCalled() + expect(mondayMock.getOportunidades).not.toHaveBeenCalled() + const resumen = json.mock.calls[0]?.[0] as Record + expect((resumen['misLeads'] as unknown[]).length).toBe(2) + expect((resumen['misOportunidades'] as { items: unknown[] }).items).toHaveLength(1) + expect(resumen['misActividadesHoy']).toEqual([]) + expect(resumen['conteos']).toMatchObject({ actividadesAbiertas: 0 }) + }) + + it('Monday se cae a media lectura (502): cae al resumen local', async () => { + mondayMock.getLeads.mockRejectedValue(new AppError(502, 'Monday no respondió')) + prismaMock.leadCaptura.findMany.mockResolvedValue([ + capturaFake({ leadId: 'loc_l1', board: 'LEADS', pendienteSync: true }), + ]) + const { res, json } = resFake() + + await resumenMiDiaEndpoint(reqFake, res) + + const resumen = json.mock.calls[0]?.[0] as Record + expect((resumen['misLeads'] as { id: string }[]).map((l) => l.id)).toEqual(['loc_l1']) + }) + + it('Monday vivo: unión — las capturas pendientes se agregan con pendienteSync (badge ⏳)', async () => { + mondayMock.getLeads.mockResolvedValue([ + { + id: '9001', + nombre: 'Lead Monday', + createdAt: '2026-09-01T10:00:00.000Z', + updatedAt: '2026-09-02T10:00:00.000Z', + estado: 'Nuevo', + origen: 'Web', + telefono: '', + email: '', + vendedorIds: [123], + }, + ]) + prismaMock.leadCaptura.findMany.mockResolvedValue([ + capturaFake({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true, updatedAt: new Date('2026-09-05T10:00:00') }), + ]) + const { res, json } = resFake() + + await resumenMiDiaEndpoint(reqFake, res) + + const resumen = json.mock.calls[0]?.[0] as Record + const leads = resumen['misLeads'] as { id: string; pendienteSync?: boolean }[] + expect(leads.map((l) => l.id)).toEqual(['loc_n1', '9001']) // el local, más reciente, primero + expect(leads[0]?.pendienteSync).toBe(true) + expect(leads[1]?.pendienteSync).toBeUndefined() // el de Monday no lleva flag + // Y la captura quedó marcada en Mis capturas + const capturas = resumen['misCapturas'] as { leadId: string; pendienteSync?: boolean; estado: string }[] + expect(capturas.find((c) => c.leadId === 'loc_n1')).toMatchObject({ pendienteSync: true, estado: 'NUEVO' }) + }) + + it('Monday vivo y sin pendientes: respuesta idéntica a la de hoy (sin flags)', async () => { + const { res, json } = resFake() + + await resumenMiDiaEndpoint(reqFake, res) + + const resumen = json.mock.calls[0]?.[0] as Record + expect(resumen['misLeads']).toEqual([]) + expect(JSON.stringify(resumen)).not.toContain('pendienteSync') + }) + + it('un error no-Monday se propaga (no se traga nada)', async () => { + mondayMock.getLeads.mockRejectedValue(new AppError(500, 'bug')) + await expect(resumenMiDiaEndpoint(reqFake, resFake().res)).rejects.toThrow('bug') + }) +}) diff --git a/api-gateway/src/controllers/mi-dia.controller.ts b/api-gateway/src/controllers/mi-dia.controller.ts index 735c34b..ccd0048 100644 --- a/api-gateway/src/controllers/mi-dia.controller.ts +++ b/api-gateway/src/controllers/mi-dia.controller.ts @@ -1,7 +1,9 @@ import type { Request, Response } from 'express' import { prisma } from '../services/prisma.js' import { mondayService } from '../services/monday.service.js' -import { resumenMiDia } from '../services/mi-dia.service.js' +import { resumenMiDia, resumenMiDiaLocal, unirPendientesLocales } from '../services/mi-dia.service.js' +import { saludMonday } from '../services/salud-monday.service.js' +import { esFalloMonday } from '../services/sync-monday.service.js' import { config } from '../config/index.js' import { AppError } from '../utils/errors.js' @@ -62,16 +64,30 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise orderBy: { createdAt: 'desc' }, }) - res.json( - await resumenMiDia( + const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId } + + // Monday caído (circuit breaker / MODO_LOCAL_FORZADO): todo desde local + if (!saludMonday.estaDisponible()) { + res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes)) + return + } + + try { + const resumen = await resumenMiDia( mondayService, - { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId }, + usuarioMiDia, nombresPorMondayId, capturas, clientes, new Date(), config.automatizaciones.vendedorCompartidoId > 0 ? config.automatizaciones.vendedorCompartidoId : null, capturadosIds, - ), - ) + ) + // Unión: capturas locales aún no sincronizadas se agregan con badge ⏳ + res.json(unirPendientesLocales(resumen, capturas.filter((c) => c.pendienteSync), usuario.nombre)) + } catch (err) { + // Monday se cayó a media lectura: mismo resumen, pero todo local + if (!esFalloMonday(err)) throw err + res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes)) + } } diff --git a/api-gateway/src/controllers/oportunidades.controller.test.ts b/api-gateway/src/controllers/oportunidades.controller.test.ts index c7bbb8b..c3dd2c1 100644 --- a/api-gateway/src/controllers/oportunidades.controller.test.ts +++ b/api-gateway/src/controllers/oportunidades.controller.test.ts @@ -1,9 +1,35 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' import { cambiarEtapaOportunidad, + cambiarEtapaOportunidadEndpoint, ETAPAS_OPORTUNIDAD_PERMITIDAS, perderOportunidad, + perderOportunidadEndpoint, } from './oportunidades.controller.js' +import { mondayService } from '../services/monday.service.js' +import { saludMonday } from '../services/salud-monday.service.js' +import { AppError } from '../utils/errors.js' + +// ---------- Prisma mockeado (los tests no tocan la BD) ---------- + +const prismaMock = vi.hoisted(() => ({ + leadCaptura: { + create: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + syncPendiente: { + create: vi.fn(), + }, + automatizacionState: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + $transaction: vi.fn(), +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) /** Monday mockeado: registra los cambios de columna y los motivos creados */ function mondayMock() { @@ -120,3 +146,117 @@ describe('cambiarEtapaOportunidad (POST /api/oportunidades/:id/etapa)', () => { ]) }) }) + +// ---------- Modo local (saludMonday caído / ids loc_* / fallback 502) ---------- + +describe('ruteo modo local (endpoints de oportunidades)', () => { + function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } + } + + const reqCon = (overrides: Record): Request => + ({ + body: {}, + params: {}, + usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' }, + ...overrides, + }) as unknown as Request + + function tiposEncolados(): string[] { + return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo) + } + + beforeEach(() => { + vi.clearAllMocks() + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.syncPendiente.create.mockResolvedValue({}) + prismaMock.automatizacionState.findUnique.mockResolvedValue(null) + prismaMock.automatizacionState.upsert.mockResolvedValue({}) + prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('cambiarEtapaOportunidadEndpoint con id loc_*: etapa local + CAMBIO_ETAPA encolado, sin tocar Monday', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) // Monday vivo: el id loc_* manda + const spyCambiar = vi.spyOn(mondayService, 'cambiarValorColumna') + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'OPORTUNIDADES' }) + const { res, json } = resFake() + + await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { etapa: 'Cotizador' } }), res) + + expect(spyCambiar).not.toHaveBeenCalled() + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_1' }, + data: { etapa: 'Cotizador', pendienteSync: true }, + }) + expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA']) + expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', etapa: 'Cotizador' }) + }) + + it("cambiarEtapaOportunidadEndpoint 'Aparto' con Monday caído: crea el apartado local (crmLocal.apartar)", async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + prismaMock.leadCaptura.findUnique.mockResolvedValue({ + leadId: 'loc_op', + usuarioId: 'u-1', + nombreLead: 'Luis Demo', + telefono: '6641112233', + email: null, + desarrollo: 'ATELIER', + modelo: 'SOHO', + datosJson: null, + }) + const { res, json } = resFake() + + await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res) + + // Oportunidad en Aparto + apartado local en Pago Apartado + cola + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_op' }, + data: { etapa: 'Aparto', pendienteSync: true }, + }) + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado', nombreLead: 'Luis Demo' }), + }) + expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA', 'CREAR_APARTADO']) + expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' }) + }) + + it('cambiarEtapaOportunidadEndpoint con id Monday y 502: fallback local con fila sombra', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) + vi.spyOn(mondayService, 'cambiarValorColumna').mockRejectedValue(new AppError(502, 'Monday no respondió')) + const { res, json } = resFake() + + await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'opp-88' }, body: { etapa: 'No Asistio' } }), res) + + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ leadId: 'opp-88', board: 'OPORTUNIDADES', mondayItemId: 'opp-88' }), + }) + expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA']) + expect(json).toHaveBeenCalledWith({ itemId: 'opp-88', etapa: 'No Asistio' }) + }) + + it('perderOportunidadEndpoint con Monday caído: descarte local (motivoItemId null) + DESCARTE encolado', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_9', nombreLead: 'Luis Demo' }) + const { res, json } = resFake() + + await perderOportunidadEndpoint( + reqCon({ params: { mondayItemId: 'loc_9' }, body: { motivo: 'Precio', comentario: 'Caro' } }), + res, + ) + + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_9' }, + data: expect.objectContaining({ etapa: 'Perdido', motivo: 'Precio' }), + }) + expect(tiposEncolados()).toEqual(['DESCARTE']) + expect(json).toHaveBeenCalledWith({ mondayItemId: 'loc_9', etapa: 'Perdido', motivo: 'Precio', motivoItemId: null }) + }) +}) diff --git a/api-gateway/src/controllers/oportunidades.controller.ts b/api-gateway/src/controllers/oportunidades.controller.ts index 816d9e5..bbbd5e1 100644 --- a/api-gateway/src/controllers/oportunidades.controller.ts +++ b/api-gateway/src/controllers/oportunidades.controller.ts @@ -4,6 +4,9 @@ import { config } from '../config/index.js' import { mondayService, type MondayService } from '../services/monday.service.js' import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js' import { automatizacionesService } from '../services/automatizaciones.service.js' +import { saludMonday } from '../services/salud-monday.service.js' +import { crmLocal, esIdLocal } from '../services/crm-local.service.js' +import { esFalloMonday } from '../services/sync-monday.service.js' /** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */ export const MOTIVOS_NO_COMPRA = [ @@ -69,7 +72,29 @@ export async function perderOportunidad( export async function perderOportunidadEndpoint(req: Request, res: Response): Promise { const body = perderSchema.parse(req.body) const mondayItemId = String(req.params['mondayItemId']) - const motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario) + let motivoItemId: string | null = null + // Modo local (id loc_* o Monday caído / fallback 502): descarte local + + // DESCARTE encolado; el motivo del tablero catálogo lo crea el worker + if (esIdLocal(mondayItemId) || !saludMonday.estaDisponible()) { + await crmLocal.descartar(mondayItemId, body.motivo, { + fuente: 'oportunidad', + comentario: body.comentario, + usuarioId: req.usuarioToken?.sub, + sombra: !esIdLocal(mondayItemId), + }) + } else { + try { + motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario) + } catch (err) { + if (!esFalloMonday(err)) throw err + await crmLocal.descartar(mondayItemId, body.motivo, { + fuente: 'oportunidad', + comentario: body.comentario, + usuarioId: req.usuarioToken?.sub, + sombra: !esIdLocal(mondayItemId), + }) + } + } res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId }) } @@ -123,21 +148,55 @@ export async function cambiarEtapaOportunidad( await moverAGrupoSegunEstado(monday, boardOportunidades, itemId) } +/** + * Cambio de etapa en modo local: guarda la etapa en la fila local y encola + * CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar), + * el equivalente al asegurarApartado del flujo Monday. + */ +async function cambiarEtapaLocal( + itemId: string, + etapa: (typeof ETAPAS_OPORTUNIDAD_PERMITIDAS)[number], + fecha: string | undefined, + usuarioId: string | undefined, +): Promise { + if (etapa === 'Aparto') { + await crmLocal.apartar(itemId, usuarioId) + return + } + await crmLocal.cambiarEstado(itemId, etapa, 'OPORTUNIDADES', { + fecha, + usuarioId, + sombra: !esIdLocal(itemId), + }) +} + /** POST /api/oportunidades/:itemId/etapa { etapa, fecha? } */ export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Response): Promise { const body = cambiarEtapaSchema.parse(req.body) const itemId = String(req.params['itemId']) - await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha) - // En 'Aparto' el apartado debe existir YA (Mis apartados recarga al volver): - // asegurarApartado reintenta unos segundos (Monday tarda en propagar la - // etapa) y es idempotente (dedup); el webhook queda como red de seguridad - // para cambios hechos directo en Monday. - if (body.etapa === 'Aparto') { + const usuarioSub = req.usuarioToken?.sub + if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { + await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub) + } else { try { - await automatizacionesService.asegurarApartado(itemId) + await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha) } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo crear el apartado de la oportunidad ${itemId}:`, err) + if (!esFalloMonday(err)) throw err + await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub) + res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) }) + return + } + // En 'Aparto' el apartado debe existir YA (Mis apartados recarga al volver): + // asegurarApartado reintenta unos segundos (Monday tarda en propagar la + // etapa) y es idempotente (dedup); el webhook queda como red de seguridad + // para cambios hechos directo en Monday. + if (body.etapa === 'Aparto') { + try { + await automatizacionesService.asegurarApartado(itemId) + } catch (err) { + // eslint-disable-next-line no-console + console.error(`No se pudo crear el apartado de la oportunidad ${itemId}:`, err) + } } } res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) }) diff --git a/api-gateway/src/controllers/planes.controller.test.ts b/api-gateway/src/controllers/planes.controller.test.ts index 0134d5e..1c8d15b 100644 --- a/api-gateway/src/controllers/planes.controller.test.ts +++ b/api-gateway/src/controllers/planes.controller.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { notificarEscrituracionSiLiquidado, resolverFiltroMios } from './planes.controller.js' +// ---------- Prisma mockeado: el encolado de la réplica no toca la BD ---------- + +const prismaMock = vi.hoisted(() => ({ + syncPendiente: { + create: vi.fn(), + }, +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) + /** Monday mockeado: registra cambios de columna */ function mondayMock() { return { @@ -12,6 +22,11 @@ function mondayMock() { } describe('notificarEscrituracionSiLiquidado (puente cobranza → postventa)', () => { + beforeEach(() => { + vi.clearAllMocks() + prismaMock.syncPendiente.create.mockResolvedValue({}) + }) + it('plan LIQUIDADO con mondayItemId: mueve el apartado a "X Escriturar"', async () => { const monday = mondayMock() const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday) @@ -30,13 +45,21 @@ describe('notificarEscrituracionSiLiquidado (puente cobranza → postventa)', () expect(monday.cambios).toEqual([]) }) - it('un fallo de Monday no rompe el abono: loguea y devuelve false', async () => { + it('un fallo de Monday no rompe el abono: loguea, encola LIQUIDADO_ESCRITURAR y devuelve false', async () => { const monday = mondayMock() vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído')) const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}) const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday) expect(ok).toBe(false) expect(spyError).toHaveBeenCalled() + // La escrituración queda encolada para que el worker la aplique al volver Monday + expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tipo: 'LIQUIDADO_ESCRITURAR', + payloadJson: JSON.stringify({ mondayItemId: 'apt-55' }), + referenciaLocal: 'apt-55', + }), + }) spyError.mockRestore() }) }) diff --git a/api-gateway/src/controllers/planes.controller.ts b/api-gateway/src/controllers/planes.controller.ts index f12a028..71cb256 100644 --- a/api-gateway/src/controllers/planes.controller.ts +++ b/api-gateway/src/controllers/planes.controller.ts @@ -1,6 +1,7 @@ import type { Request, Response } from 'express' import { z } from 'zod' import { prisma } from '../services/prisma.js' +import { encolarSyncSeguro } from '../services/crm-local.service.js' import { alCorriente, avancePlan, @@ -231,6 +232,8 @@ export async function notificarEscrituracionSiLiquidado( } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo mover el apartado ${plan.mondayItemId} a 'X Escriturar':`, err) + // La escrituración queda encolada (LIQUIDADO_ESCRITURAR) para el worker + await encolarSyncSeguro('LIQUIDADO_ESCRITURAR', { mondayItemId: plan.mondayItemId }, plan.mondayItemId) return false } } diff --git a/api-gateway/src/controllers/prefill-fallback.test.ts b/api-gateway/src/controllers/prefill-fallback.test.ts new file mode 100644 index 0000000..ca05564 --- /dev/null +++ b/api-gateway/src/controllers/prefill-fallback.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' +import { AppError, NotFoundError } from '../utils/errors.js' + +// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ---------- + +const prismaMock = vi.hoisted(() => ({ + leadCaptura: { + findUnique: vi.fn(), + findFirst: vi.fn(), + }, + cliente: { + findFirst: vi.fn(), + }, + cotizacion: { + findFirst: vi.fn(), + }, +})) + +const mondayMock = vi.hoisted(() => ({ + getApartados: vi.fn(), + getContactos: vi.fn(), + getContactoDetalle: vi.fn(), + getOportunidades: vi.fn(), +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) +vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) + +import { prefillApartadoEndpoint } from './clientes.controller.js' +import { saludMonday } from '../services/salud-monday.service.js' + +/** + * Fallback del prefill del alta (GET /api/altas/prefill-apartado/:itemId): + * Monday caído/502 → el prefill se arma desde LeadCaptura (apartado local) + + * Cliente + cotización local, con los campos que el front ya consume. + */ + +const apartadoLocal = { + id: 'row-a1', + leadId: 'loc_a1', + nombreLead: 'ANA DEMO', + telefono: '6649998877', + email: 'ana@correo.mx', + origen: 'WhatsApp', + desarrollo: 'SENDA', + modelo: '1R', + board: 'APARTADO', + etapa: 'Pago Apartado', + usuarioId: 'u-1', + datosJson: null, + mondayItemId: null, + pendienteSync: true, + createdAt: new Date('2026-09-02T10:00:00'), + updatedAt: new Date('2026-09-03T10:00:00'), +} + +const oportunidadLocal = { + ...apartadoLocal, + id: 'row-o1', + leadId: 'loc_o1', + board: 'OPORTUNIDADES', + etapa: 'Aparto', + datosJson: JSON.stringify({ credito: 'Infonavit', edad: 35 }), +} + +const clienteLocal = { + id: 'c1', + nombreCompleto: 'ANA DEMO', + curp: 'CURP123', + rfc: 'RFC123', + email: 'ana@correo.mx', + celular: '6649998877', + proyecto: 'SENDA', + unidadCodigo: 'SENDA-101', + mondayApartadoId: 'loc_a1', + createdAt: new Date('2026-09-02T11:00:00'), +} + +const cotizacionLocal = { + id: 'cot1', + unidadCodigo: 'SENDA-101', + tipoPropuesta: 'INFONAVIT', + acabados: true, + apartado: 20000, + precioLista: 4_415_000, + precioFinal: 4_400_000, + enganche: 440_000, + numMensualidades: 12, + mensualidad: 30000, + pagoFinal: 100_000, + mondayItemId: 'loc_o1', + createdAt: new Date('2026-09-01T10:00:00'), +} + +function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible') + saludMonday.restablecer() + prismaMock.leadCaptura.findUnique.mockResolvedValue(apartadoLocal) + prismaMock.leadCaptura.findFirst.mockResolvedValue(oportunidadLocal) + prismaMock.cliente.findFirst.mockResolvedValue(clienteLocal) + prismaMock.cotizacion.findFirst.mockResolvedValue(cotizacionLocal) +}) + +describe('prefillApartadoEndpoint (fallback local)', () => { + it('Monday caído: prefill desde apartado local + Cliente + oportunidad local + cotización local', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + const { res, json } = resFake() + + await prefillApartadoEndpoint({ params: { itemId: 'loc_a1' } } as unknown as Request, res) + + expect(mondayMock.getApartados).not.toHaveBeenCalled() + const prefill = json.mock.calls[0]?.[0] as Record + expect(prefill['apartado']).toMatchObject({ + id: 'loc_a1', + nombre: 'ANA DEMO', + telefono: '6649998877', + email: 'ana@correo.mx', + unidadCodigo: 'SENDA-101', + estado: 'Pago Apartado', + precioFinal: 4_400_000, + }) + expect(prefill['contacto']).toMatchObject({ + nombre: 'ANA DEMO', + Teléfono: '6649998877', + CURP: 'CURP123', + RFC: 'RFC123', + }) + expect(prefill['oportunidad']).toMatchObject({ + id: 'loc_o1', + desarrollo: 'SENDA', + modeloInteres: '1R', + credito: 'Infonavit', + }) + expect(prefill['cotizacion']).toMatchObject({ tipoPropuesta: 'INFONAVIT', acabados: true, precioFinal: 4_400_000 }) + }) + + it('Monday caído y el apartado no existe en local → 404', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + + await expect( + prefillApartadoEndpoint({ params: { itemId: 'nope' } } as unknown as Request, resFake().res), + ).rejects.toThrow(NotFoundError) + }) + + it('Monday lanza 502: cae al prefill local; el 404 de Monday se propaga intacto', async () => { + mondayMock.getApartados.mockRejectedValue(new AppError(502, 'Monday no respondió')) + const { res, json } = resFake() + await prefillApartadoEndpoint({ params: { itemId: 'loc_a1' } } as unknown as Request, res) + expect((json.mock.calls[0]?.[0] as Record)['apartado']).toMatchObject({ id: 'loc_a1' }) + + // Apartado inexistente en Monday (404): NO es fallo de Monday, se propaga + mondayMock.getApartados.mockResolvedValue([]) + await expect( + prefillApartadoEndpoint({ params: { itemId: 'x' } } as unknown as Request, resFake().res), + ).rejects.toThrow(NotFoundError) + }) + + it('sin Cliente ni oportunidad local: contacto/oportunidad van en null (el front los tolera)', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + prismaMock.cliente.findFirst.mockResolvedValue(null) + prismaMock.leadCaptura.findFirst.mockResolvedValue(null) + prismaMock.cotizacion.findFirst.mockResolvedValue(null) + const { res, json } = resFake() + + await prefillApartadoEndpoint({ params: { itemId: 'loc_a1' } } as unknown as Request, res) + + const prefill = json.mock.calls[0]?.[0] as Record + expect(prefill['contacto']).toBeNull() + expect(prefill['oportunidad']).toBeNull() + expect(prefill['cotizacion']).toBeNull() + expect(prefill['apartado']).toMatchObject({ id: 'loc_a1', unidadCodigo: '' }) + }) +}) diff --git a/api-gateway/src/controllers/sistema.controller.ts b/api-gateway/src/controllers/sistema.controller.ts new file mode 100644 index 0000000..0b040b5 --- /dev/null +++ b/api-gateway/src/controllers/sistema.controller.ts @@ -0,0 +1,15 @@ +import type { Request, Response } from 'express' +import { prisma } from '../services/prisma.js' +import { saludMonday } from '../services/salud-monday.service.js' + +/** + * GET /api/sistema/estado — salud del modo local para el banner del front: + * `mondayOk` (false = operar en modo local) y `pendientesSync` (registros de + * la cola SyncPendiente esperando réplica a Monday, incluidos los en ERROR). + */ +export async function estadoSistema(_req: Request, res: Response): Promise { + const pendientesSync = await prisma.syncPendiente.count({ + where: { estado: { in: ['PENDIENTE', 'ERROR'] } }, + }) + res.json({ mondayOk: saludMonday.estaDisponible(), pendientesSync }) +} diff --git a/api-gateway/src/controllers/unidades.controller.test.ts b/api-gateway/src/controllers/unidades.controller.test.ts new file mode 100644 index 0000000..f415259 --- /dev/null +++ b/api-gateway/src/controllers/unidades.controller.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' +import { AppError, NotFoundError } from '../utils/errors.js' + +// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ---------- + +const prismaMock = vi.hoisted(() => ({ + unidad: { + findMany: vi.fn(), + findUnique: vi.fn(), + }, +})) + +const mondayMock = vi.hoisted(() => ({ + getUnidades: vi.fn(), + getUnidad: vi.fn(), +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) +vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) + +import { listarUnidades, obtenerUnidad } from './unidades.controller.js' +import { saludMonday } from '../services/salud-monday.service.js' + +const filaLocal = { + codigo: 'U-101', + desarrollo: 'DIAGONAL', + modelo: '2RA', + supConstruccion: 70, + supExcedente: 0, + supEstacionamiento: 12.5, + precioLista: 2_000_000, + precioAcabados: 2_100_000, + status: 'DISPONIBLE', + etapa: 'Entrega inmediata', +} + +function resFake() { + const json = vi.fn() + return { res: { json } as unknown as Response, json } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible') + saludMonday.restablecer() + prismaMock.unidad.findMany.mockResolvedValue([filaLocal]) + prismaMock.unidad.findUnique.mockResolvedValue(filaLocal) +}) + +describe('unidades.controller (fallback local)', () => { + it('Monday vivo: devuelve Monday y no toca la tabla local', async () => { + mondayMock.getUnidades.mockResolvedValue([{ codigo: 'M-1', status: 'DISPONIBLE', desarrollo: 'SENDA' }]) + const { res, json } = resFake() + + await listarUnidades({ query: {} } as unknown as Request, res) + + expect(json).toHaveBeenCalledWith([{ codigo: 'M-1', status: 'DISPONIBLE', desarrollo: 'SENDA' }]) + expect(prismaMock.unidad.findMany).not.toHaveBeenCalled() + }) + + it('Monday caído (circuit breaker): lista desde la tabla Unidad con la misma forma de DTO', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + const { res, json } = resFake() + + await listarUnidades({ query: {} } as unknown as Request, res) + + expect(mondayMock.getUnidades).not.toHaveBeenCalled() + const lista = json.mock.calls[0]?.[0] as Record[] + expect(lista).toHaveLength(1) + expect(lista[0]).toMatchObject({ + codigo: 'U-101', + modelo: '2RA', + precioSinAcabados: 2_000_000, + precioConAcabados: 2_100_000, + status: 'DISPONIBLE', + desarrollo: 'DIAGONAL', + etapa: 'Entrega inmediata', + }) + }) + + it('Monday caído: los filtros ?disponibles y ?desarrollo se aplican igual', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + prismaMock.unidad.findMany.mockResolvedValue([ + filaLocal, + { ...filaLocal, codigo: 'U-102', status: 'APARTADO' }, + { ...filaLocal, codigo: 'S-1', desarrollo: 'SENDA' }, + ]) + const { res, json } = resFake() + + await listarUnidades({ query: { disponibles: 'true', desarrollo: 'senda' } } as unknown as Request, res) + + const lista = json.mock.calls[0]?.[0] as Record[] + expect(lista.map((u) => u['codigo'])).toEqual(['S-1']) + }) + + it('Monday lanza 502: fallback local; otro error se propaga', async () => { + mondayMock.getUnidades.mockRejectedValue(new AppError(502, 'Monday no respondió')) + const { res, json } = resFake() + await listarUnidades({ query: {} } as unknown as Request, res) + expect((json.mock.calls[0]?.[0] as unknown[]).length).toBe(1) + + mondayMock.getUnidades.mockRejectedValue(new AppError(500, 'bug')) + await expect(listarUnidades({ query: {} } as unknown as Request, resFake().res)).rejects.toThrow('bug') + }) + + it('obtenerUnidad: Monday caído → local; inexistente en ambos → 404', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + const { res, json } = resFake() + + await obtenerUnidad({ params: { codigo: 'U-101' } } as unknown as Request, res) + expect((json.mock.calls[0]?.[0] as Record)['codigo']).toBe('U-101') + + prismaMock.unidad.findUnique.mockResolvedValue(null) + await expect( + obtenerUnidad({ params: { codigo: 'U-X' } } as unknown as Request, resFake().res), + ).rejects.toThrow(NotFoundError) + }) +}) diff --git a/api-gateway/src/controllers/unidades.controller.ts b/api-gateway/src/controllers/unidades.controller.ts index 0b90b38..75bb09c 100644 --- a/api-gateway/src/controllers/unidades.controller.ts +++ b/api-gateway/src/controllers/unidades.controller.ts @@ -1,10 +1,13 @@ import type { Request, Response } from 'express' -import { mondayService } from '../services/monday.service.js' +import { listarUnidadesConFallback, obtenerUnidadConFallback } from '../services/unidades-local.service.js' import { NotFoundError } from '../utils/errors.js' -/** GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE; ?desarrollo= filtra por desarrollo */ +/** + * GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE; ?desarrollo= filtra por desarrollo. + * Monday caído (o 502) → espejo local (tabla Unidad), mismo contrato de respuesta. + */ export async function listarUnidades(req: Request, res: Response): Promise { - const unidades = await mondayService.getUnidades() + const unidades = await listarUnidadesConFallback() const disponibles = req.query['disponibles'] === 'true' const desarrollo = req.query['desarrollo']?.toString().toUpperCase() let lista = disponibles ? unidades.filter((u) => u.status === 'DISPONIBLE') : unidades @@ -14,9 +17,9 @@ export async function listarUnidades(req: Request, res: Response): Promise res.json(lista) } -/** GET /api/unidades/:codigo — 404 si no existe */ +/** GET /api/unidades/:codigo — 404 si no existe ni en Monday ni en el espejo local */ export async function obtenerUnidad(req: Request, res: Response): Promise { - const unidad = await mondayService.getUnidad(String(req.params['codigo'])) + const unidad = await obtenerUnidadConFallback(String(req.params['codigo'])) if (!unidad) throw new NotFoundError(`Unidad ${String(req.params['codigo'])} no encontrada`) res.json(unidad) } diff --git a/api-gateway/src/index.ts b/api-gateway/src/index.ts index 899f7b4..f625236 100644 --- a/api-gateway/src/index.ts +++ b/api-gateway/src/index.ts @@ -2,10 +2,44 @@ import cron from 'node-cron' import { crearApp } from './app.js' import { config } from './config/index.js' import { automatizacionesService } from './services/automatizaciones.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 @@ -18,6 +52,12 @@ if (config.automatizaciones.enabled) { // 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) { @@ -55,6 +95,11 @@ if (config.automatizaciones.enabled) { // 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) => { diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 3e9ca45..22bc5ec 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -81,6 +81,7 @@ import { } from '../controllers/ai.controller.js' import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js' import { resumenReportes } from '../controllers/reportes.controller.js' +import { estadoSistema } from '../controllers/sistema.controller.js' export const apiRouter = Router() @@ -174,6 +175,9 @@ apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint) apiRouter.get('/reportes/resumen', resumenReportes) +// Estado del modo local (banner del front): salud de Monday + cola de sync +apiRouter.get('/sistema/estado', estadoSistema) + apiRouter.post('/webhooks/monday', webhookMonday) apiRouter.get('/automatizaciones/estado', estadoAutomatizaciones) diff --git a/api-gateway/src/services/actualizar-disponibilidad.test.ts b/api-gateway/src/services/actualizar-disponibilidad.test.ts new file mode 100644 index 0000000..68487dd --- /dev/null +++ b/api-gateway/src/services/actualizar-disponibilidad.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// ---------- Prisma mockeado (los tests no tocan la BD) ---------- + +const prismaMock = vi.hoisted(() => ({ + unidad: { + updateMany: vi.fn(), + }, + syncPendiente: { + create: vi.fn(), + }, +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) + +import { MondayService } from './monday.service.js' +import { saludMonday } from './salud-monday.service.js' + +/** + * Fallback de actualizarDisponibilidadUnidad (modo local): con Monday caído o + * lanzando 502, el status se actualiza en el espejo local (tabla Unidad) y la + * réplica queda encolada (DISPONIBILIDAD_UNIDAD) para el sync worker. + */ + +const crearServicio = (): MondayService => + new MondayService({ apiKey: 'test', apiUrl: 'https://api.test/v2', baseDelayMs: 1 }) + +const respuesta500 = (): Response => + new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }) + +beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible') + vi.unstubAllGlobals() + saludMonday.restablecer() + prismaMock.unidad.updateMany.mockResolvedValue({ count: 1 }) + prismaMock.syncPendiente.create.mockResolvedValue({}) +}) + +describe('actualizarDisponibilidadUnidad (fallback local)', () => { + it('Monday caído (circuit breaker): actualiza el espejo local y encola DISPONIBILIDAD_UNIDAD sin fetch', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + const fetchSpy = vi.fn() + vi.stubGlobal('fetch', fetchSpy) + + await crearServicio().actualizarDisponibilidadUnidad('U-101', 'APARTADA') + + expect(fetchSpy).not.toHaveBeenCalled() + expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({ + where: { codigo: 'U-101' }, + data: { status: 'APARTADA' }, + }) + expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({ + data: { + tipo: 'DISPONIBILIDAD_UNIDAD', + payloadJson: JSON.stringify({ codigo: 'U-101', status: 'APARTADA' }), + referenciaLocal: 'U-101', + }, + }) + }) + + it('Monday lanza 502 a media operación: mismo fallback local', async () => { + vi.stubGlobal('fetch', async () => respuesta500()) + + await crearServicio().actualizarDisponibilidadUnidad('U-102', 'VENDIDA') + + expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({ + where: { codigo: 'U-102' }, + data: { status: 'VENDIDA' }, + }) + expect(prismaMock.syncPendiente.create).toHaveBeenCalledOnce() + }) + + it('unidad inexistente en el espejo local → 404 y no encola', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + prismaMock.unidad.updateMany.mockResolvedValue({ count: 0 }) + + await expect(crearServicio().actualizarDisponibilidadUnidad('U-X', 'APARTADA')).rejects.toThrow( + 'Unidad U-X no encontrada', + ) + expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled() + }) + + it('un fallo al encolar no rompe la escritura local (best-effort)', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + prismaMock.syncPendiente.create.mockRejectedValue(new Error('DB llena')) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await crearServicio().actualizarDisponibilidadUnidad('U-101', 'APARTADA') + + expect(prismaMock.unidad.updateMany).toHaveBeenCalledOnce() + consoleSpy.mockRestore() + }) +}) diff --git a/api-gateway/src/services/contactos-local.service.test.ts b/api-gateway/src/services/contactos-local.service.test.ts new file mode 100644 index 0000000..594937b --- /dev/null +++ b/api-gateway/src/services/contactos-local.service.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// ---------- Prisma mockeado (los tests no tocan la BD) ---------- + +const prismaMock = vi.hoisted(() => ({ + cliente: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + leadCaptura: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + cotizacion: { + findMany: vi.fn(), + }, + documento: { + findMany: vi.fn(), + }, + usuario: { + findMany: vi.fn(), + }, +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) + +import { detalleContactoLocal, listarContactosLocal } from './contactos-local.service.js' + +function clienteFake(parcial: Record & { id: string }) { + return { + nombreCompleto: `Cliente ${parcial.id}`, + curp: 'CURP123', + rfc: 'RFC123', + email: null, + celular: null, + proyecto: '', + unidadCodigo: null, + payloadJson: '{}', + mondayContactoId: null, + mondayApartadoId: null, + capturadoPorId: null, + createdAt: new Date('2026-09-01T10:00:00'), + ...parcial, + } +} + +function capturaFake(parcial: Record & { leadId: string }) { + return { + id: `row-${parcial.leadId}`, + nombreLead: `Persona ${parcial.leadId}`, + telefono: null, + email: null, + origen: null, + desarrollo: null, + modelo: null, + board: 'OPORTUNIDADES', + etapa: 'Afluencia', + usuarioId: 'u-1', + datosJson: null, + mondayItemId: null, + pendienteSync: true, + createdAt: new Date('2026-09-02T10:00:00'), + updatedAt: new Date('2026-09-03T10:00:00'), + ...parcial, + } +} + +beforeEach(() => { + vi.clearAllMocks() + prismaMock.cliente.findMany.mockResolvedValue([]) + prismaMock.cliente.findFirst.mockResolvedValue(null) + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + prismaMock.leadCaptura.findMany.mockResolvedValue([]) + prismaMock.cotizacion.findMany.mockResolvedValue([]) + prismaMock.documento.findMany.mockResolvedValue([]) + prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', nombre: 'Martin Zavala' }]) +}) + +describe('listarContactosLocal', () => { + it('arma la lista desde Cliente + capturas (OPORTUNIDADES/APARTADO) con la forma de ContactoMonday + fuente local', async () => { + prismaMock.cliente.findMany.mockResolvedValue([ + clienteFake({ id: 'c1', nombreCompleto: 'JUAN PÉREZ', celular: '4771234567', email: 'juan@correo.mx', proyecto: 'SENDA' }), + ]) + prismaMock.leadCaptura.findMany.mockResolvedValue([ + capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877', etapa: 'Pago Apartado', board: 'APARTADO' }), + ]) + + const lista = await listarContactosLocal() + + expect(lista).toHaveLength(2) + expect(lista[0]).toMatchObject({ + id: 'loc_1', // updatedAt más reciente primero + nombre: 'ANA DEMO', + telefono: '6649998877', + estado: 'Pago Apartado', + fuente: 'local', + }) + expect(lista[1]).toMatchObject({ + id: 'c1', + nombre: 'JUAN PÉREZ', + telefono: '4771234567', + email: 'juan@correo.mx', + estado: 'Cliente', + fuente: 'local', + }) + }) + + it('deduplica por teléfono (gana el Cliente: tiene la ficha completa)', async () => { + prismaMock.cliente.findMany.mockResolvedValue([ + clienteFake({ id: 'c1', celular: '+52 477 123 4567' }), + ]) + prismaMock.leadCaptura.findMany.mockResolvedValue([ + capturaFake({ leadId: 'loc_1', telefono: '4771234567' }), + ]) + + const lista = await listarContactosLocal() + expect(lista).toHaveLength(1) + expect(lista[0]?.id).toBe('c1') + }) +}) + +describe('detalleContactoLocal', () => { + it('null si el id no existe en ninguna tabla local', async () => { + expect(await detalleContactoLocal('nope')).toBeNull() + }) + + it('por cliente.id: contacto + propiedad + relaciones + historial derivado de lo local', async () => { + const cliente = clienteFake({ + id: 'c1', + nombreCompleto: 'JUAN PÉREZ', + celular: '4771234567', + email: 'juan@correo.mx', + proyecto: 'SENDA', + unidadCodigo: 'SENDA-101', + mondayApartadoId: 'loc_a1', + capturadoPorId: 'u-1', + createdAt: new Date('2026-09-01T10:00:00'), + }) + prismaMock.cliente.findFirst.mockResolvedValue(cliente) + prismaMock.leadCaptura.findMany.mockResolvedValue([ + capturaFake({ leadId: 'loc_a1', board: 'APARTADO', etapa: 'Pago Apartado', telefono: '4771234567' }), + capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Aparto', telefono: '4771234567', desarrollo: 'SENDA', modelo: '1R' }), + capturaFake({ leadId: 'loc_x', board: 'LEADS', etapa: 'NUEVO', telefono: '9990001111' }), // otro teléfono: fuera + ]) + prismaMock.cotizacion.findMany.mockResolvedValue([ + { + id: 'cot1', + unidadCodigo: 'SENDA-101', + tipoPropuesta: 'BANCARIO', + precioFinal: 4_400_000, + vendedorId: 'u-1', + createdAt: new Date('2026-08-30T10:00:00'), + }, + ]) + prismaMock.documento.findMany.mockResolvedValue([ + { id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') }, + ]) + + const detalle = await detalleContactoLocal('c1') + expect(detalle).not.toBeNull() + if (detalle === null) return + + expect(detalle.contacto).toMatchObject({ + nombre: 'JUAN PÉREZ', + Teléfono: '4771234567', + Email: 'juan@correo.mx', + CURP: 'CURP123', + RFC: 'RFC123', + }) + expect(detalle.propiedad).toMatchObject({ + id: 'loc_a1', + unidadCodigo: 'SENDA-101', + desarrollo: 'SENDA', + estado: 'Pago Apartado', + precioFinal: 4_400_000, + atendidoPor: 'Martin Zavala', + }) + expect(detalle.relaciones.oportunidades).toHaveLength(1) + expect(detalle.relaciones.oportunidades[0]).toMatchObject({ + id: 'loc_o1', + estado: 'Aparto', + atendidoPor: 'Martin Zavala', + detalle: 'SENDA · 1R', + }) + expect(detalle.relaciones.apartados[0]).toMatchObject({ id: 'loc_a1', detalle: 'SENDA-101' }) + expect(detalle.relaciones.leads).toHaveLength(0) // el de otro teléfono no entra + + const detalles = detalle.historial.map((h) => h.detalle) + expect(detalles).toContain('Alta de cliente registrada (SENDA-101)') + expect(detalles).toContain('Documento subido (INE)') + expect(detalles).toContain('Cotización SENDA-101 (BANCARIO)') + expect(detalles.some((d) => d.startsWith('Captura en la app'))).toBe(true) + }) + + it('por leadId (loc_*): contacto desde la captura, sin propiedad', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue( + capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877', etapa: 'Afluencia' }), + ) + prismaMock.leadCaptura.findMany.mockResolvedValue([ + capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877' }), + ]) + + const detalle = await detalleContactoLocal('loc_1') + expect(detalle).not.toBeNull() + if (detalle === null) return + expect(detalle.contacto).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877', Estado: 'Afluencia' }) + expect(detalle.propiedad).toBeNull() + expect(detalle.relaciones.oportunidades).toHaveLength(1) + expect(detalle.historial.length).toBeGreaterThan(0) + }) +}) diff --git a/api-gateway/src/services/contactos-local.service.ts b/api-gateway/src/services/contactos-local.service.ts new file mode 100644 index 0000000..f18ba9d --- /dev/null +++ b/api-gateway/src/services/contactos-local.service.ts @@ -0,0 +1,211 @@ +import type { ContactoMonday } from './monday.service.js' +import { claveTelefono, type DetalleContacto, type EventoHistorialContacto, type RelacionCrm } from './contactos-detalle.service.js' +import { prisma } from './prisma.js' + +/** + * Fallback de LECTURA del módulo Contactos (modo local): cuando Monday está + * caído, la lista y el detalle se arman desde las tablas locales (Cliente + + * LeadCaptura + Documento + Cotizacion) con la MISMA forma de DTO que el + * camino Monday. Lo que no existe en local (activity log, columnas del board) + * va vacío; el historial se deriva de las capturas/altas/cotizaciones locales. + */ + +/** Contacto de la lista con flag aditivo de procedencia (el front lo ignora o lo muestra) */ +export type ContactoListaLocal = ContactoMonday & { fuente?: 'local' } + +/** + * Lista local: Clientes (alta de clientes) + capturas del embudo en boards + * OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la + * ficha completa), más reciente primero, máx 100 (como getContactos). + */ +export async function listarContactosLocal(): Promise { + const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' }, take: 100 }) + const capturas = await prisma.leadCaptura.findMany({ + where: { board: { in: ['OPORTUNIDADES', 'APARTADO'] } }, + orderBy: { updatedAt: 'desc' }, + take: 100, + }) + + const vistos = new Set() + const lista: ContactoListaLocal[] = [] + const agregar = (entrada: ContactoListaLocal): void => { + const clave = claveTelefono(entrada.telefono) + if (clave !== '') { + if (vistos.has(clave)) return + vistos.add(clave) + } + lista.push(entrada) + } + + for (const c of clientes) { + agregar({ + id: c.id, + nombre: c.nombreCompleto, + telefono: c.celular ?? '', + email: c.email ?? '', + estado: c.proyecto.trim() !== '' ? 'Cliente' : '', + updatedAt: c.createdAt.toISOString(), + fuente: 'local', + }) + } + for (const c of capturas) { + agregar({ + id: c.leadId, + nombre: c.nombreLead, + telefono: c.telefono ?? '', + email: c.email ?? '', + estado: c.etapa, + updatedAt: c.updatedAt.toISOString(), + fuente: 'local', + }) + } + return lista.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 100) +} + +/** + * Detalle local del contacto. El id puede ser cliente.id, leadId (loc_* o id + * Monday sombra) o mondayContactoId. Las relaciones se arman por teléfono + * (últimos 10 dígitos) o email sobre LeadCaptura, igual que el camino Monday; + * el historial se deriva de capturas + alta + documentos + cotizaciones + * locales (no hay activity log en local). null si no existe en ninguna tabla. + */ +export async function detalleContactoLocal(contactoId: string): Promise { + let cliente = await prisma.cliente.findFirst({ + where: { OR: [{ id: contactoId }, { mondayContactoId: contactoId }] }, + }) + const capturaBase = cliente + ? null + : await prisma.leadCaptura.findUnique({ where: { leadId: contactoId } }) + if (cliente === null && capturaBase === null) return null + + const telefono = cliente?.celular ?? capturaBase?.telefono ?? '' + const email = (cliente?.email ?? capturaBase?.email ?? '').trim().toLowerCase() + const clave = claveTelefono(telefono) + + // Si el id era de una captura, el Cliente ligado se busca por celular + if (cliente === null && clave !== '') { + cliente = await prisma.cliente.findFirst({ + where: { celular: { contains: clave } }, + orderBy: { createdAt: 'desc' }, + }) + } + + const capturasTodas = await prisma.leadCaptura.findMany({ orderBy: { createdAt: 'desc' } }) + const relacionadas = capturasTodas.filter( + (c) => + (clave !== '' && c.telefono !== null && claveTelefono(c.telefono) === clave) || + (email !== '' && (c.email ?? '').trim().toLowerCase() === email), + ) + + // Cotizaciones y documentos locales del contacto + const cotizaciones = + clave !== '' + ? await prisma.cotizacion.findMany({ + where: { clienteTelefono: { contains: clave } }, + orderBy: { createdAt: 'desc' }, + }) + : [] + const documentos = cliente + ? await prisma.documento.findMany({ where: { clienteId: cliente.id }, orderBy: { createdAt: 'desc' } }) + : [] + + // Nombres de capturistas/vendedores (ids locales → nombre para mostrar) + const idsUsuarios = [ + ...new Set([ + ...relacionadas.map((c) => c.usuarioId), + ...(cliente?.capturadoPorId ? [cliente.capturadoPorId] : []), + ...cotizaciones.map((c) => c.vendedorId).filter((id): id is string => id !== null), + ]), + ] + const usuarios = idsUsuarios.length + ? await prisma.usuario.findMany({ where: { id: { in: idsUsuarios } }, select: { id: true, nombre: true } }) + : [] + const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre])) + const nombreDe = (id: string | null): string | null => (id !== null ? (nombrePorId.get(id) ?? null) : null) + + const unidadCliente = cliente?.unidadCodigo ?? cotizaciones[0]?.unidadCodigo ?? '' + const aRelacion = (c: (typeof relacionadas)[number], detalle: string): RelacionCrm => ({ + id: c.leadId, + nombre: c.nombreLead, + estado: c.etapa, + atendidoPor: nombreDe(c.usuarioId), + detalle, + }) + const relaciones = { + leads: relacionadas + .filter((c) => c.board === 'LEADS') + .map((c) => aRelacion(c, c.origen ?? '')), + oportunidades: relacionadas + .filter((c) => c.board === 'OPORTUNIDADES') + .map((c) => aRelacion(c, [c.desarrollo, c.modelo].filter((p) => p !== null && p !== '').join(' · '))), + apartados: relacionadas + .filter((c) => c.board === 'APARTADO') + .map((c) => aRelacion(c, c.leadId === cliente?.mondayApartadoId ? unidadCliente : '')), + } + + // Propiedad: ficha del Cliente (la comprada o en proceso); sin Cliente no hay + let propiedad: DetalleContacto['propiedad'] = null + if (cliente !== null) { + const apartadoLocal = relacionadas.find((c) => c.leadId === cliente.mondayApartadoId) + propiedad = { + id: cliente.mondayApartadoId ?? cliente.id, + unidadCodigo: unidadCliente, + desarrollo: cliente.proyecto, + estado: apartadoLocal?.etapa ?? '', + precioFinal: cotizaciones[0]?.precioFinal ?? 0, + fechaCompra: cliente.createdAt.toISOString().slice(0, 10), + fechaLimite: '', + atendidoPor: nombreDe(cliente.capturadoPorId), + } + } + + const historial: EventoHistorialContacto[] = [ + ...relacionadas.map((c) => ({ + fechaISO: c.createdAt.toISOString(), + usuario: nombreDe(c.usuarioId), + detalle: `Captura en la app (${c.nombreLead})`, + fuente: 'App', + })), + ...(cliente !== null + ? [ + { + fechaISO: cliente.createdAt.toISOString(), + usuario: nombreDe(cliente.capturadoPorId), + detalle: `Alta de cliente registrada${cliente.unidadCodigo !== null ? ` (${cliente.unidadCodigo})` : ''}`, + fuente: 'App', + }, + ] + : []), + ...documentos.map((d) => ({ + fechaISO: d.createdAt.toISOString(), + usuario: null, + detalle: `Documento subido (${d.tipo})`, + fuente: 'App', + })), + ...cotizaciones.map((c) => ({ + fechaISO: c.createdAt.toISOString(), + usuario: nombreDe(c.vendedorId), + detalle: `Cotización ${c.unidadCodigo} (${c.tipoPropuesta})`, + fuente: 'App', + })), + ].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO)) + + const contacto: Record = + cliente !== null + ? { + nombre: cliente.nombreCompleto, + Teléfono: cliente.celular ?? '', + Email: cliente.email ?? '', + CURP: cliente.curp, + RFC: cliente.rfc, + Estado: 'Cliente', + } + : { + nombre: capturaBase!.nombreLead, + Teléfono: capturaBase!.telefono ?? '', + Email: capturaBase!.email ?? '', + Estado: capturaBase!.etapa, + } + + return { contacto, propiedad, relaciones, historial } +} diff --git a/api-gateway/src/services/crm-local.service.test.ts b/api-gateway/src/services/crm-local.service.test.ts new file mode 100644 index 0000000..b5e9e58 --- /dev/null +++ b/api-gateway/src/services/crm-local.service.test.ts @@ -0,0 +1,368 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// ---------- Prisma mockeado (los tests no tocan la BD) ---------- + +const prismaMock = vi.hoisted(() => ({ + leadCaptura: { + create: vi.fn(), + findFirst: vi.fn(), + findUnique: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + leadOportunidad: { + create: vi.fn(), + findUnique: vi.fn(), + }, + syncPendiente: { + create: vi.fn(), + }, + automatizacionState: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + cliente: { + findMany: vi.fn(), + }, + $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) + +import { CrmLocalService, esIdLocal } from './crm-local.service.js' + +const svc = new CrmLocalService() + +/** Tipos de sync encolados, en orden, a partir de las llamadas al mock */ +function tiposEncolados(): string[] { + return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo) +} + +function payloadEncolado(tipo: string): Record { + const llamada = prismaMock.syncPendiente.create.mock.calls.find( + (c) => (c[0] as { data: { tipo: string } }).data.tipo === tipo, + ) + if (!llamada) throw new Error(`No se encoló ${tipo}`) + return JSON.parse((llamada[0] as { data: { payloadJson: string } }).data.payloadJson) as Record +} + +beforeEach(() => { + vi.clearAllMocks() + prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) + prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ id: 'row-nueva', ...data })) + prismaMock.leadCaptura.findFirst.mockResolvedValue(null) // default: sin duplicados + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => data) + prismaMock.leadOportunidad.create.mockResolvedValue({}) + prismaMock.syncPendiente.create.mockResolvedValue({}) + prismaMock.automatizacionState.findUnique.mockResolvedValue(null) + prismaMock.automatizacionState.upsert.mockResolvedValue({}) +}) + +describe('esIdLocal', () => { + it('distingue ids locales de ids de Monday', () => { + expect(esIdLocal('loc_123e4567-e89b-12d3-a456-426614174000')).toBe(true) + expect(esIdLocal('1234567890')).toBe(false) + }) +}) + +describe('CrmLocalService.crearLead', () => { + it('crea la captura board=LEADS etapa NUEVO pendienteSync y encola CREAR_LEAD + ASIGNAR_PERSONA', async () => { + const r = await svc.crearLead( + { nombre: 'Ana Prueba', telefono: '4771234567', origen: 'WhatsApp', desarrollo: 'SENDA', notas: 'Quiere 2R' }, + 'u-1', + ) + expect('duplicado' in r).toBe(false) + if ('duplicado' in r) return + expect(r.id).toMatch(/^loc_/) + expect(r.oportunidadId).toBeNull() + + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + leadId: r.id, + usuarioId: 'u-1', + nombreLead: 'Ana Prueba', + board: 'LEADS', + etapa: 'NUEVO', + origen: 'WhatsApp', + desarrollo: 'SENDA', + pendienteSync: true, + }), + }) + expect(tiposEncolados()).toEqual(['CREAR_LEAD', 'ASIGNAR_PERSONA']) + const payload = payloadEncolado('CREAR_LEAD') + expect(payload['datos']).toMatchObject({ nombre: 'Ana Prueba', telefono: '4771234567', origenLabel: 'WhatsApp' }) + expect(payloadEncolado('ASIGNAR_PERSONA')).toEqual({ board: 'leads', usuarioId: 'u-1' }) + // Todo quedó en una sola transacción (cambio local + cola atómicos) + expect(prismaMock.$transaction).toHaveBeenCalledOnce() + }) + + it('sin teléfono repetido no hay duplicado; con teléfono existente devuelve { duplicado }', async () => { + prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_viejo', nombreLead: 'Ana Prueba' }) + const r = await svc.crearLead({ nombre: 'Ana Prueba', telefono: '4771234567' }) + expect(r).toEqual({ duplicado: { id: 'loc_viejo', nombre: 'Ana Prueba' } }) + expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() + expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled() + }) +}) + +describe('CrmLocalService.crearAfluencia', () => { + it('crea la oportunidad local (board=OPORTUNIDADES, etapa Afluencia) con perfil en datosJson y encola CREAR_AFLUENCIA', async () => { + const r = await svc.crearAfluencia( + { + nombre: 'Luis Demo', + telefono: '6641112233', + desarrollo: 'ATELIER', + modelo: 'SOHO', + edad: 35, + credito: 'Infonavit', + loQuierePara: 'Habitarla', + }, + 'u-2', + ) + expect('duplicado' in r).toBe(false) + if ('duplicado' in r) return + expect(r.id).toMatch(/^loc_/) + expect(r.oportunidadId).toBe(r.id) // la afluencia ES la oportunidad + + const creada = prismaMock.leadCaptura.create.mock.calls[0]?.[0] as { data: Record } + expect(creada.data).toMatchObject({ + board: 'OPORTUNIDADES', + etapa: 'Afluencia', + modelo: 'SOHO', + pendienteSync: true, + }) + // Perfilamiento completo en datosJson + expect(JSON.parse(creada.data['datosJson'] as string)).toMatchObject({ edad: 35, credito: 'Infonavit' }) + // Auto-mapping leadId = oportunidadId (como el flujo Monday) + expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ data: { leadId: r.id, oportunidadId: r.id } }) + expect(tiposEncolados()).toEqual(['CREAR_AFLUENCIA', 'ASIGNAR_PERSONA']) + expect(payloadEncolado('CREAR_AFLUENCIA')['datos']).toMatchObject({ + nombre: 'Luis Demo', + modeloLabel: 'SOHO', + creditoLabel: 'Infonavit', + loQuierePara: 'Habitarla', + }) + }) + + it('con oportunidadId ACTUALIZA la fila local y encola ACTUALIZAR_AFLUENCIA (no crea duplicado)', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'op-55', nombreLead: 'Luis Demo' }) + const r = await svc.crearAfluencia({ nombre: 'Luis Demo', oportunidadId: 'op-55', califica: 'Si' }, 'u-2') + expect(r).toEqual({ id: 'op-55', oportunidadId: 'op-55', actualizada: true }) + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'op-55' }, + data: expect.objectContaining({ etapa: 'Afluencia', pendienteSync: true }), + }) + expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() + expect(tiposEncolados()).toEqual(['ACTUALIZAR_AFLUENCIA']) + expect(payloadEncolado('ACTUALIZAR_AFLUENCIA')['datos']).toMatchObject({ nombre: 'Luis Demo', calificaLabel: 'Si' }) + }) + + it('con teléfono duplicado en OPORTUNIDADES devuelve { duplicado }', async () => { + prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_op', nombreLead: 'Luis Demo' }) + const r = await svc.crearAfluencia({ nombre: 'Luis Demo', telefono: '6641112233' }) + expect(r).toEqual({ duplicado: { id: 'loc_op', nombre: 'Luis Demo' } }) + }) +}) + +describe('CrmLocalService.cambiarEstado', () => { + it('actualiza la etapa local y encola CAMBIO_ETAPA con board y label', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'OPORTUNIDADES' }) + await svc.cambiarEstado('loc_1', 'Agendo Cita', 'OPORTUNIDADES', { fecha: '2026-09-10' }) + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_1' }, + data: { etapa: 'Agendo Cita', pendienteSync: true }, + }) + expect(payloadEncolado('CAMBIO_ETAPA')).toEqual({ board: 'oportunidades', label: 'Agendo Cita', fecha: '2026-09-10' }) + }) + + it('sin fila local y sin sombra → 404', async () => { + await expect(svc.cambiarEstado('loc_inexistente', 'Contactado', 'LEADS')).rejects.toMatchObject({ status: 404 }) + }) + + it('con sombra crea la fila mínima (id Monday viejo) y encola igual', async () => { + await svc.cambiarEstado('998877', 'Contactado', 'LEADS', { sombra: true, usuarioId: 'u-1' }) + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ leadId: '998877', board: 'LEADS', mondayItemId: '998877', pendienteSync: true }), + }) + expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA']) + }) +}) + +describe('CrmLocalService.descartar', () => { + it('marca etapa terminal Perdido con motivo/submotivo y encola DESCARTE', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_2', nombreLead: 'Luis Demo' }) + await svc.descartar('loc_2', 'Precio', { subcategoria: 'Enganche', comentario: 'Se le hizo caro' }) + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_2' }, + data: { etapa: 'Perdido', motivo: 'Precio', submotivo: 'Enganche', pendienteSync: true }, + }) + expect(payloadEncolado('DESCARTE')).toEqual({ + fuente: 'oportunidad', + motivo: 'Precio', + nombreItem: 'Luis Demo', + subcategoria: 'Enganche', + comentario: 'Se le hizo caro', + }) + }) + + it('fuente lead: etapa terminal Descartado', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_3', nombreLead: 'Ana' }) + await svc.descartar('loc_3', 'Timing', { fuente: 'lead' }) + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_3' }, + data: expect.objectContaining({ etapa: 'Descartado' }), + }) + }) +}) + +describe('CrmLocalService.convertirLead', () => { + const filaLead = { + leadId: 'loc_lead', + oportunidadId: null, + usuarioId: 'u-1', + nombreLead: 'Ana Prueba', + telefono: '4771234567', + email: null, + origen: 'WhatsApp', + desarrollo: 'SENDA', + modelo: null, + datosJson: '{"notas":null}', + } + + it('marca el lead Calificado, crea la oportunidad en Agendo Cita, liga y encola CONVERSION', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue(filaLead) + const op = await svc.convertirLead('loc_lead', 'u-1') + expect(op.leadId).toMatch(/^loc_/) + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_lead' }, + data: { etapa: 'Calificado', oportunidadId: op.leadId, pendienteSync: true }, + }) + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + leadId: op.leadId, + board: 'OPORTUNIDADES', + etapa: 'Agendo Cita', + nombreLead: 'Ana Prueba', + pendienteSync: true, + }), + }) + expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ + data: { leadId: 'loc_lead', oportunidadId: op.leadId }, + }) + const payload = payloadEncolado('CONVERSION') + expect(payload['oportunidadIdLocal']).toBe(op.leadId) + expect(payload['datos']).toMatchObject({ nombre: 'Ana Prueba', estadoLabel: 'Agendo Cita', telefono: '4771234567' }) + }) + + it('si el lead ya fue convertido → 409 como el flujo Monday', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ ...filaLead, oportunidadId: 'loc_op_previa' }) + await expect(svc.convertirLead('loc_lead')).rejects.toMatchObject({ status: 409 }) + expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() + }) + + it('lead inexistente → 404', async () => { + await expect(svc.convertirLead('loc_fantasma')).rejects.toMatchObject({ status: 404 }) + }) +}) + +describe('CrmLocalService.apartar', () => { + const filaOportunidad = { + leadId: 'loc_op', + usuarioId: 'u-1', + nombreLead: 'Luis Demo', + telefono: '6641112233', + email: 'luis@correo.com', + desarrollo: 'ATELIER', + modelo: 'SOHO', + datosJson: '{"edad":35}', + } + + it('pone la oportunidad en Aparto, crea el apartado local (Pago Apartado) y encola CAMBIO_ETAPA + CREAR_APARTADO', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad) + const apartado = await svc.apartar('loc_op', 'u-1') + expect(apartado.leadId).toMatch(/^loc_/) + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_op' }, + data: { etapa: 'Aparto', pendienteSync: true }, + }) + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + board: 'APARTADO', + etapa: 'Pago Apartado', + nombreLead: 'Luis Demo', + pendienteSync: true, + }), + }) + // Dedup compatible con las automatizaciones + expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { clave: 'apartado-creado:loc_op' } }), + ) + expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA', 'CREAR_APARTADO']) + expect(payloadEncolado('CREAR_APARTADO')['datos']).toMatchObject({ + clienteNombre: 'Luis Demo', + estadoLabel: 'Pago Apartado', + telefono: '6641112233', + email: 'luis@correo.com', + }) + }) + + it('idempotente: con el dedup escrito devuelve el apartado existente sin encolar de nuevo', async () => { + prismaMock.leadCaptura.findUnique.mockImplementation(async ({ where }: { where: { leadId: string } }) => + where.leadId === 'loc_op' ? filaOportunidad : { leadId: 'loc_ap_previo', board: 'APARTADO' }, + ) + prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'apartado-creado:loc_op', valor: 'loc_ap_previo' }) + const apartado = await svc.apartar('loc_op', 'u-1') + expect(apartado.leadId).toBe('loc_ap_previo') + expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() + expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled() + }) +}) + +describe('CrmLocalService.buscarLocales', () => { + it('combina LeadCaptura y Cliente con la forma del picker del alta', async () => { + prismaMock.leadCaptura.findMany.mockResolvedValue([ + { + leadId: 'loc_1', + nombreLead: 'Ana Prueba', + telefono: '4771234567', + email: null, + etapa: 'Afluencia', + origen: 'WhatsApp', + board: 'OPORTUNIDADES', + desarrollo: 'SENDA', + modelo: 'SOHO', + updatedAt: new Date('2026-09-05T10:00:00Z'), + }, + ]) + prismaMock.cliente.findMany.mockResolvedValue([ + { + id: 'cli-9', + nombreCompleto: 'Ana Cliente', + celular: '6641112233', + email: 'ana@correo.com', + proyecto: 'ATELIER', + createdAt: new Date('2026-09-04T10:00:00Z'), + }, + ]) + const r = await svc.buscarLocales('ana') + expect(r).toHaveLength(2) + // Más reciente primero: la captura (sep-05) antes que el cliente (sep-04) + expect(r[0]).toEqual({ + id: 'loc_1', + nombre: 'Ana Prueba', + telefono: '4771234567', + email: '', + estado: 'Afluencia', + origen: 'WhatsApp', + fuente: 'oportunidad', + desarrollo: 'SENDA', + modeloInteres: 'SOHO', + }) + expect(r[1]).toMatchObject({ id: 'cli-9', nombre: 'Ana Cliente', fuente: 'oportunidad', desarrollo: 'ATELIER' }) + expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] } }), + ) + }) +}) diff --git a/api-gateway/src/services/crm-local.service.ts b/api-gateway/src/services/crm-local.service.ts new file mode 100644 index 0000000..9950555 --- /dev/null +++ b/api-gateway/src/services/crm-local.service.ts @@ -0,0 +1,574 @@ +import { randomUUID } from 'node:crypto' +import { AppError, NotFoundError } from '../utils/errors.js' +import { claveApartadoCreado } from './automatizaciones.service.js' +import { prisma } from './prisma.js' +import type { TipoSync } from './sync-monday.service.js' + +/** + * CRM local (modo degradado): implementa el embudo completo contra SQLite + * mientras Monday está caído y encola cada operación en SyncPendiente para que + * el sync worker la replique cuando Monday vuelva. + * + * - Entidad local del embudo: LeadCaptura (board LEADS | OPORTUNIDADES | + * APARTADO + etapa). Ids locales: `loc_` (ver `esIdLocal`). + * - Las operaciones que tocan varias tablas van en $transaction; el encolado + * viaja en la MISMA transacción para que nunca quede un cambio local sin su + * réplica pendiente. + * - SECUENCIAL a propósito (SQLite P1008): nada de Promise.all con escrituras. + * + * Los controllers rutean aquí cuando `saludMonday.estaDisponible()` es false, + * cuando el id es `loc_*` (siempre local hasta que se sincronice) o como + * fallback tras un AppError 502 de Monday. + */ + +/** Board local del embudo (columna LeadCaptura.board) */ +export type BoardLocal = 'LEADS' | 'OPORTUNIDADES' | 'APARTADO' + +/** Board local → valor del payload CAMBIO_ETAPA (boards de config.monday.boards) */ +const BOARD_PAYLOAD: Record = { + LEADS: 'leads', + OPORTUNIDADES: 'oportunidades', + APARTADO: 'apartado', +} + +/** true si el id es local (aún no existe en Monday) */ +export function esIdLocal(id: string): boolean { + return id.startsWith('loc_') +} + +function idLocal(): string { + return `loc_${randomUUID()}` +} + +const hoyMas = (dias: number): string => { + const d = new Date() + d.setDate(d.getDate() + dias) + const m = String(d.getMonth() + 1).padStart(2, '0') + const dia = String(d.getDate()).padStart(2, '0') + return `${d.getFullYear()}-${m}-${dia}` +} + +/** Captura rápida de lead (forma corta de /hoy) */ +export interface DatosLeadLocal { + nombre: string + telefono?: string | undefined + email?: string | undefined + origen?: string | undefined + desarrollo?: string | undefined + notas?: string | undefined +} + +/** Alta Afluencia: captura ligera + perfilamiento completo */ +export interface DatosAfluenciaLocal extends DatosLeadLocal { + /** Con valor: se ACTUALIZA esa oportunidad en vez de crear una nueva */ + oportunidadId?: string | undefined + modelo?: string | undefined + medioContacto?: string | undefined + edad?: number | undefined + estadoCivil?: string | undefined + tipoEmpleo?: string | undefined + ocupacion?: string | undefined + rangoSalarial?: string | undefined + credito?: string | undefined + tipoEconomia?: string | undefined + califica?: string | undefined + loQuierePara?: string | undefined +} + +/** Misma forma que ResultadoCrearLead del controller (sin importarlo: ciclo) */ +export type ResultadoCapturaLocal = + | { id: string; oportunidadId: string | null; actualizada?: boolean } + | { duplicado: { id: string; nombre: string } } + +/** Resultado del buscador local (misma forma que ResultadoBusquedaLead) */ +export interface ResultadoBusquedaLocal { + id: string + nombre: string + telefono: string + email: string + estado: string + origen: string + fuente: 'lead' | 'oportunidad' + desarrollo: string + modeloInteres: string +} + +/** + * Crea un registro en la cola SyncPendiente. Es el ÚNICO punto de encolado del + * modo local: los handlers del worker (sync-handlers.ts) consumen `tipo` + + * `payloadJson` y actualizan los ids Monday usando `referenciaLocal`. + */ +export async function encolarSync( + tipo: TipoSync, + payload: Record, + referenciaLocal?: string | null, +): Promise { + await prisma.syncPendiente.create({ + data: { + tipo, + payloadJson: JSON.stringify(payload), + referenciaLocal: referenciaLocal ?? null, + }, + }) +} + +/** + * Versión best-effort para las réplicas que hoy solo se loguean (alta cliente, + * cotización, checkbox de documento, liquidación): un fallo al encolar NUNCA + * rompe el flujo del caller. + */ +export async function encolarSyncSeguro( + tipo: TipoSync, + payload: Record, + referenciaLocal?: string | null, +): Promise { + try { + await encolarSync(tipo, payload, referenciaLocal) + } catch (err) { + // eslint-disable-next-line no-console + console.error(`[sync] no se pudo encolar ${tipo}:`, err) + } +} + +export class CrmLocalService { + /** + * Captura rápida local: LeadCaptura board=LEADS etapa 'NUEVO' + CREAR_LEAD + * encolado. Dedup por teléfono entre los leads locales (mismo contrato 409 + * que el flujo Monday). Con usuarioId también se encola la auto-asignación + * del capturista (ASIGNAR_PERSONA) para que quede como su vendedor al + * sincronizarse. + */ + async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise { + if (datos.telefono !== undefined) { + const existente = await prisma.leadCaptura.findFirst({ + where: { telefono: datos.telefono, board: 'LEADS' }, + }) + if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } } + } + const id = idLocal() + const datosMonday = { + nombre: datos.nombre, + telefono: datos.telefono, + email: datos.email, + origenLabel: datos.origen, + desarrolloLabel: datos.desarrollo, + notas: datos.notas, + } + const operaciones = [ + prisma.leadCaptura.create({ + data: { + leadId: id, + oportunidadId: null, // los leads no generan oportunidad + usuarioId: usuarioId ?? 'desconocido', + nombreLead: datos.nombre, + telefono: datos.telefono ?? null, + email: datos.email ?? null, + origen: datos.origen ?? null, + desarrollo: datos.desarrollo ?? null, + board: 'LEADS', + etapa: 'NUEVO', + datosJson: JSON.stringify({ notas: datos.notas ?? null }), + pendienteSync: true, + }, + }), + prisma.syncPendiente.create({ + data: { tipo: 'CREAR_LEAD', payloadJson: JSON.stringify({ datos: datosMonday }), referenciaLocal: id }, + }), + ] + if (usuarioId !== undefined) { + operaciones.push( + prisma.syncPendiente.create({ + data: { + tipo: 'ASIGNAR_PERSONA', + payloadJson: JSON.stringify({ board: 'leads', usuarioId }), + referenciaLocal: id, + }, + }), + ) + } + await prisma.$transaction(operaciones) + return { id, oportunidadId: null } + } + + /** + * Alta Afluencia local. Sin oportunidadId: LeadCaptura board=OPORTUNIDADES + * etapa 'Afluencia' con el perfilamiento en datosJson (además de los campos + * planos) + auto-mapping LeadOportunidad + CREAR_AFLUENCIA encolado. Con + * oportunidadId (botón "Afluencia" de Mis oportunidades): actualiza la fila + * local si existe y encola ACTUALIZAR_AFLUENCIA (el id puede ser de Monday). + */ + async crearAfluencia(datos: DatosAfluenciaLocal, usuarioId?: string): Promise { + const { oportunidadId, modelo, medioContacto, edad, estadoCivil, tipoEmpleo, ocupacion } = datos + const { rangoSalarial, credito, tipoEconomia, califica, loQuierePara } = datos + const datosMonday = { + nombre: datos.nombre, + telefono: datos.telefono, + email: datos.email, + desarrolloLabel: datos.desarrollo, + origenLabel: datos.origen, + modeloLabel: modelo, + medioContacto, + edad, + estadoCivilLabel: estadoCivil, + tipoEmpleoLabel: tipoEmpleo, + ocupacion, + rangoSalarial, + creditoLabel: credito, + tipoEconomiaLabel: tipoEconomia, + calificaLabel: califica, + loQuierePara, + notas: datos.notas, + } + + if (oportunidadId !== undefined) { + const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } }) + const operaciones = [] + if (existente) { + operaciones.push( + prisma.leadCaptura.update({ + where: { leadId: oportunidadId }, + data: { + nombreLead: datos.nombre, + telefono: datos.telefono ?? null, + email: datos.email ?? null, + origen: datos.origen ?? null, + desarrollo: datos.desarrollo ?? null, + modelo: modelo ?? null, + etapa: 'Afluencia', + datosJson: JSON.stringify(datos), + pendienteSync: true, + }, + }), + ) + } + operaciones.push( + prisma.syncPendiente.create({ + data: { + tipo: 'ACTUALIZAR_AFLUENCIA', + payloadJson: JSON.stringify({ oportunidadId, datos: datosMonday }), + referenciaLocal: oportunidadId, + }, + }), + ) + await prisma.$transaction(operaciones) + return { id: oportunidadId, oportunidadId, actualizada: true } + } + + if (datos.telefono !== undefined) { + const duplicado = await prisma.leadCaptura.findFirst({ + where: { telefono: datos.telefono, board: 'OPORTUNIDADES' }, + }) + if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } } + } + + const id = idLocal() + const operaciones = [ + prisma.leadCaptura.create({ + data: { + leadId: id, + oportunidadId: id, // la afluencia ES la oportunidad + usuarioId: usuarioId ?? 'desconocido', + nombreLead: datos.nombre, + telefono: datos.telefono ?? null, + email: datos.email ?? null, + origen: datos.origen ?? null, + desarrollo: datos.desarrollo ?? null, + modelo: modelo ?? null, + board: 'OPORTUNIDADES', + etapa: 'Afluencia', + datosJson: JSON.stringify(datos), + pendienteSync: true, + }, + }), + // Auto-mapping leadId = oportunidadId (como el flujo Monday): el alta de + // clientes encuentra así la oportunidad a mover a 'Aparto' + prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }), + prisma.syncPendiente.create({ + data: { tipo: 'CREAR_AFLUENCIA', payloadJson: JSON.stringify({ datos: datosMonday }), referenciaLocal: id }, + }), + ] + if (usuarioId !== undefined) { + operaciones.push( + prisma.syncPendiente.create({ + data: { + tipo: 'ASIGNAR_PERSONA', + payloadJson: JSON.stringify({ board: 'oportunidades', usuarioId }), + referenciaLocal: id, + }, + }), + ) + } + await prisma.$transaction(operaciones) + return { id, oportunidadId: id } + } + + /** + * Fila local del item, creándola "sombra" si no existe y `sombra` es true. + * Las sombras cubren el fallback 502 sobre ids de Monday antiguos (creados + * antes del modo local): la acción queda reflejada en Mi día y el cambio se + * encola igual. Sin `sombra` → 404. + */ + private async filaLocal(itemId: string, board: BoardLocal, usuarioId: string | undefined, sombra: boolean) { + const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) + if (row) return row + if (!sombra) throw new NotFoundError(`No existe registro local para el item ${itemId}`) + return prisma.leadCaptura.create({ + data: { + leadId: itemId, + usuarioId: usuarioId ?? 'desconocido', + nombreLead: 'Registro previo (Monday)', + board, + etapa: 'NUEVO', + mondayItemId: esIdLocal(itemId) ? null : itemId, + pendienteSync: true, + }, + }) + } + + /** + * Cambio de estado/etapa local (boards LEADS, OPORTUNIDADES y APARTADO): + * actualiza `etapa` (+updatedAt vía Prisma) y encola CAMBIO_ETAPA con el + * label real de la columna 'Estado' (y la fecha de la cita si aplica). + */ + async cambiarEstado( + itemId: string, + label: string, + board: BoardLocal, + opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {}, + ): Promise { + await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false) + const payload: Record = { board: BOARD_PAYLOAD[board], label } + if (opciones.fecha !== undefined) payload['fecha'] = opciones.fecha + await prisma.$transaction([ + prisma.leadCaptura.update({ + where: { leadId: itemId }, + data: { etapa: label, pendienteSync: true }, + }), + prisma.syncPendiente.create({ + data: { tipo: 'CAMBIO_ETAPA', payloadJson: JSON.stringify(payload), referenciaLocal: itemId }, + }), + ]) + } + + /** + * Descarte local: etapa terminal ('Descartado' en Leads, 'Perdido' en + * Oportunidades), guarda motivo/submotivo y encola DESCARTE (el handler + * replica el estado + el item del tablero Motivos No Compra). + */ + async descartar( + itemId: string, + motivo: string, + opciones: { + fuente?: 'lead' | 'oportunidad' | undefined + subcategoria?: string | undefined + comentario?: string | undefined + usuarioId?: string | undefined + sombra?: boolean | undefined + } = {}, + ): Promise { + const fuente = opciones.fuente ?? 'oportunidad' + const board: BoardLocal = fuente === 'lead' ? 'LEADS' : 'OPORTUNIDADES' + const row = await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false) + const etapaTerminal = fuente === 'lead' ? 'Descartado' : 'Perdido' + const payload: Record = { fuente, motivo, nombreItem: row.nombreLead } + if (opciones.subcategoria !== undefined) payload['subcategoria'] = opciones.subcategoria + if (opciones.comentario !== undefined) payload['comentario'] = opciones.comentario + await prisma.$transaction([ + prisma.leadCaptura.update({ + where: { leadId: itemId }, + data: { + etapa: etapaTerminal, + motivo, + submotivo: opciones.subcategoria ?? null, + pendienteSync: true, + }, + }), + prisma.syncPendiente.create({ + data: { tipo: 'DESCARTE', payloadJson: JSON.stringify(payload), referenciaLocal: itemId }, + }), + ]) + } + + /** + * Conversión local lead → oportunidad (botón "Agendo" de Mis leads): el lead + * queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando + * los datos, se liga LeadOportunidad + `oportunidadId` en la captura y se + * encola CONVERSION. Si el lead ya fue convertido → 409 (como el flujo + * Monday). Devuelve la fila local de la oportunidad. + */ + async convertirLead(leadIdLocal: string, _usuarioId?: string) { + const lead = await prisma.leadCaptura.findUnique({ where: { leadId: leadIdLocal } }) + if (!lead) throw new NotFoundError('Lead no encontrado') + if (lead.oportunidadId !== null) throw new AppError(409, 'El lead ya fue convertido en oportunidad') + + const oportunidadId = idLocal() + const datosOportunidad = { + nombre: lead.nombreLead, + estadoLabel: 'Agendo Cita', + telefono: lead.telefono ?? undefined, + email: lead.email ?? undefined, + origenLabel: lead.origen ?? undefined, + desarrolloLabel: lead.desarrollo ?? undefined, + modeloLabel: lead.modelo ?? undefined, + } + const resultados = await prisma.$transaction([ + prisma.leadCaptura.update({ + where: { leadId: leadIdLocal }, + data: { etapa: 'Calificado', oportunidadId, pendienteSync: true }, + }), + prisma.leadCaptura.create({ + data: { + leadId: oportunidadId, + oportunidadId, + usuarioId: lead.usuarioId, + nombreLead: lead.nombreLead, + telefono: lead.telefono, + email: lead.email, + origen: lead.origen, + desarrollo: lead.desarrollo, + modelo: lead.modelo, + board: 'OPORTUNIDADES', + etapa: 'Agendo Cita', + datosJson: lead.datosJson, + pendienteSync: true, + }, + }), + prisma.leadOportunidad.create({ data: { leadId: leadIdLocal, oportunidadId } }), + prisma.syncPendiente.create({ + data: { + tipo: 'CONVERSION', + payloadJson: JSON.stringify({ oportunidadIdLocal: oportunidadId, datos: datosOportunidad }), + referenciaLocal: leadIdLocal, + }, + }), + ]) + return resultados[1] + } + + /** + * "Aparto" local: la oportunidad pasa a etapa 'Aparto' y se crea el apartado + * local (board=APARTADO, etapa 'Pago Apartado', primer paso del pipeline + * Enkontrol) con los datos conocidos. Idempotente con el mismo dedup + * `apartado-creado:` que las automatizaciones. Encola CAMBIO_ETAPA + + * CREAR_APARTADO (en ese orden: la etapa primero, como en Monday). + */ + async apartar(oportunidadIdLocal: string, usuarioId?: string) { + // Sombra para ids Monday sin fila local (fallback 502 sobre items viejos) + const oportunidad = await this.filaLocal( + oportunidadIdLocal, + 'OPORTUNIDADES', + usuarioId, + !esIdLocal(oportunidadIdLocal), + ) + + const clave = claveApartadoCreado(oportunidadIdLocal) + const previo = await prisma.automatizacionState.findUnique({ where: { clave } }) + if (previo !== null) { + const existente = await prisma.leadCaptura.findUnique({ where: { leadId: previo.valor } }) + if (existente) return existente + } + + const apartadoId = idLocal() + const datosApartado = { + clienteNombre: oportunidad.nombreLead, + unidadCodigo: '', // la unidad se captura en la cotización/alta + fechaApartado: hoyMas(0), + fechaLimite: hoyMas(15), + estadoLabel: 'Pago Apartado', + telefono: oportunidad.telefono ?? undefined, + email: oportunidad.email ?? undefined, + } + const resultados = await prisma.$transaction([ + prisma.leadCaptura.update({ + where: { leadId: oportunidadIdLocal }, + data: { etapa: 'Aparto', pendienteSync: true }, + }), + prisma.leadCaptura.create({ + data: { + leadId: apartadoId, + usuarioId: oportunidad.usuarioId, + nombreLead: oportunidad.nombreLead, + telefono: oportunidad.telefono, + email: oportunidad.email, + desarrollo: oportunidad.desarrollo, + modelo: oportunidad.modelo, + board: 'APARTADO', + etapa: 'Pago Apartado', + datosJson: oportunidad.datosJson, + pendienteSync: true, + }, + }), + prisma.automatizacionState.upsert({ + where: { clave }, + update: { valor: apartadoId }, + create: { clave, valor: apartadoId }, + }), + prisma.syncPendiente.create({ + data: { + tipo: 'CAMBIO_ETAPA', + payloadJson: JSON.stringify({ board: 'oportunidades', label: 'Aparto' }), + referenciaLocal: oportunidadIdLocal, + }, + }), + prisma.syncPendiente.create({ + data: { + tipo: 'CREAR_APARTADO', + payloadJson: JSON.stringify({ datos: datosApartado }), + referenciaLocal: apartadoId, + }, + }), + ]) + return resultados[1] + } + + /** + * Búsqueda local para el picker del alta de clientes (y el buscador del + * cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular, + * con la misma forma de respuesta que la búsqueda Monday. + */ + async buscarLocales(termino: string): Promise { + const capturas = await prisma.leadCaptura.findMany({ + where: { OR: [{ nombreLead: { contains: termino } }, { telefono: { contains: termino } }] }, + orderBy: { updatedAt: 'desc' }, + take: 15, + }) + const clientes = await prisma.cliente.findMany({ + where: { OR: [{ nombreCompleto: { contains: termino } }, { celular: { contains: termino } }] }, + orderBy: { createdAt: 'desc' }, + take: 15, + }) + const candidatos = [ + ...capturas.map((c) => ({ + id: c.leadId, + nombre: c.nombreLead, + telefono: c.telefono ?? '', + email: c.email ?? '', + estado: c.etapa, + origen: c.origen ?? '', + fuente: (c.board === 'LEADS' ? 'lead' : 'oportunidad') as 'lead' | 'oportunidad', + desarrollo: c.desarrollo ?? '', + modeloInteres: c.modelo ?? '', + orden: c.updatedAt, + })), + ...clientes.map((c) => ({ + id: c.id, + nombre: c.nombreCompleto, + telefono: c.celular ?? '', + email: c.email ?? '', + estado: '', + origen: '', + fuente: 'oportunidad' as const, + desarrollo: c.proyecto, + modeloInteres: '', + orden: c.createdAt, + })), + ] + return candidatos + .sort((a, b) => b.orden.getTime() - a.orden.getTime()) + .slice(0, 15) + .map(({ orden: _orden, ...resto }) => resto) + } +} + +/** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */ +export const crmLocal = new CrmLocalService() diff --git a/api-gateway/src/services/mi-dia-local.test.ts b/api-gateway/src/services/mi-dia-local.test.ts new file mode 100644 index 0000000..62c055c --- /dev/null +++ b/api-gateway/src/services/mi-dia-local.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest' +import { + resumenMiDiaLocal, + unirPendientesLocales, + type ClienteLocalMiDia, + type FilaLocalMiDia, + type ResumenMiDia, + type UsuarioMiDia, +} from './mi-dia.service.js' + +/** + * Mi día en modo local (Monday caído) y la unión durante la transición + * (Monday vivo + capturas locales pendientes de sincronizar). + */ + +const AHORA = new Date('2026-09-05T12:00:00') + +const vendedor: UsuarioMiDia = { nombre: 'Martin Zavala', rol: 'vendedor', mondayUserId: 123 } +const sinNombre: UsuarioMiDia = { nombre: ' ', rol: 'vendedor', mondayUserId: null } + +let consecutivo = 0 +function fila(parcial: Partial & { leadId: string }): FilaLocalMiDia { + consecutivo += 1 + return { + nombreLead: `Persona ${parcial.leadId}`, + telefono: null, + email: null, + origen: null, + desarrollo: null, + modelo: null, + board: 'LEADS', + etapa: 'NUEVO', + mondayItemId: null, + pendienteSync: true, + createdAt: new Date(AHORA.getTime() - consecutivo * 3_600_000), + updatedAt: new Date(AHORA.getTime() - consecutivo * 1_800_000), + ...parcial, + } +} + +function cliente(parcial: Partial & { id: string }): ClienteLocalMiDia { + return { + nombreCompleto: `Cliente ${parcial.id}`, + proyecto: '', + unidadCodigo: null, + mondayApartadoId: null, + createdAt: new Date('2026-09-04T10:00:00'), + ...parcial, + } +} + +describe('resumenMiDiaLocal (Monday caído)', () => { + it('arma el ResumenMiDia completo desde LeadCaptura por board, sin terminales', () => { + const capturas = [ + fila({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO' }), + fila({ leadId: 'loc_l2', board: 'LEADS', etapa: 'Descartado' }), // terminal: fuera + fila({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', desarrollo: 'SENDA', modelo: '1R' }), + fila({ leadId: 'loc_o2', board: 'OPORTUNIDADES', etapa: 'Perdido' }), // terminal: fuera de listas, cuenta en porEtapa + fila({ leadId: 'loc_a1', board: 'APARTADO', etapa: 'Pago Apartado' }), + fila({ leadId: 'loc_a2', board: 'APARTADO', etapa: 'Cancelado' }), // final: fuera + ] + const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA) + + expect(r.sinMapeoMonday).toBe(false) + expect(r.misLeads.map((l) => l.id).sort()).toEqual(['loc_l1', 'loc_o1']) + expect(r.misLeads.find((l) => l.id === 'loc_l1')?.fuente).toBe('lead') + expect(r.misLeads.find((l) => l.id === 'loc_o1')?.fuente).toBe('oportunidad') + // porEtapa cuenta TODAS las oportunidades (incluye terminales, como Monday) + expect(r.misOportunidades.porEtapa).toEqual({ Afluencia: 1, Perdido: 1 }) + expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['loc_o1']) + expect(r.misOportunidades.items[0]).toMatchObject({ etapa: 'Afluencia', desarrollo: 'SENDA', modeloInteres: '1R' }) + expect(r.misApartados.map((a) => a.id)).toEqual(['loc_a1']) + expect(r.misCapturas).toHaveLength(6) + // Lo que no existe en local va vacío/cero sin romper el front + expect(r.misActividadesHoy).toEqual([]) + expect(r.apartadosPorVencer).toEqual([]) + expect(r.conteos.actividadesAbiertas).toBe(0) + expect(r.equipo).toBeUndefined() + }) + + it('ultimoCambio viene del updatedAt local y el quién es el capturista ("app" si no hay nombre)', () => { + const capturas = [fila({ leadId: 'loc_l1', board: 'LEADS', updatedAt: new Date('2026-09-05T08:00:00') })] + const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA) + expect(r.misLeads[0]?.ultimoCambio).toEqual({ + fechaISO: '2026-09-05T08:00:00.000Z', + usuario: 'Martin Zavala', + detalle: 'Actualización', + }) + + const rSinNombre = resumenMiDiaLocal(sinNombre, capturas, [], AHORA) + expect(rSinNombre.misLeads[0]?.ultimoCambio?.usuario).toBe('app') + expect(rSinNombre.sinMapeoMonday).toBe(true) + }) + + it('la unidad del apartado sale del Cliente ligado; estadoApartado de Mis clientes cruza con el apartado local', () => { + const capturas = [fila({ leadId: 'loc_a1', board: 'APARTADO', etapa: 'Pago Apartado' })] + const clientes = [ + cliente({ id: 'c1', mondayApartadoId: 'loc_a1', unidadCodigo: 'SENDA-101', proyecto: 'SENDA' }), + cliente({ id: 'c2' }), + ] + const r = resumenMiDiaLocal(vendedor, capturas, clientes, AHORA) + + expect(r.misApartados[0]?.unidadCodigo).toBe('SENDA-101') + const c1 = r.misClientes.find((c) => c.id === 'c1') + expect(c1).toMatchObject({ completado: true, estadoApartado: 'Pago Apartado' }) + expect(r.misClientes.find((c) => c.id === 'c2')?.estadoApartado).toBeNull() + }) + + it('leadsNuevosSemana cuenta capturas de LEADS + OPORTUNIDADES de los últimos 7 días', () => { + const capturas = [ + fila({ leadId: 'loc_l1', board: 'LEADS', createdAt: new Date('2026-09-04T00:00:00') }), + fila({ leadId: 'loc_o1', board: 'OPORTUNIDADES', createdAt: new Date('2026-09-01T00:00:00') }), + fila({ leadId: 'loc_o2', board: 'OPORTUNIDADES', createdAt: new Date('2026-08-20T00:00:00') }), // > 7 días + fila({ leadId: 'loc_a1', board: 'APARTADO', createdAt: new Date('2026-09-04T00:00:00') }), // apartado no cuenta + ] + const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA) + expect(r.conteos.leadsNuevosSemana).toBe(2) + }) +}) + +// ---------- Unión (Monday vivo + pendientes locales) ---------- + +function resumenBase(): ResumenMiDia { + return { + generadoEn: AHORA.toISOString(), + sinMapeoMonday: false, + misLeads: [ + { + id: '9001', + nombre: 'Lead Monday', + telefono: '', + email: '', + estado: 'Nuevo', + origen: 'Web', + updatedAt: '2026-09-04T10:00:00.000Z', + fuente: 'lead', + ultimoCambio: null, + }, + ], + misCapturas: [ + { leadId: 'loc_n1', nombre: 'Cap Local', telefono: '', estado: '', origen: '', capturadoEn: '2026-09-03T10:00:00.000Z' }, + { leadId: '9001', nombre: 'Lead Monday', telefono: '', estado: 'Nuevo', origen: 'Web', capturadoEn: '2026-09-02T10:00:00.000Z' }, + ], + misClientes: [], + misActividadesHoy: [], + apartadosPorVencer: [], + misOportunidades: { + porEtapa: { Afluencia: 1 }, + items: [ + { + id: '9002', + nombre: 'Op Monday', + telefono: '', + email: '', + etapa: 'Afluencia', + desarrollo: '', + modeloInteres: '', + unidadCodigo: '', + updatedAt: '2026-09-04T09:00:00.000Z', + ultimoCambio: null, + }, + ], + }, + misApartados: [ + { id: '9003', nombre: 'Apt Monday', unidadCodigo: 'U-1', estado: 'Pago Apartado', fechaLimite: '2026-09-10', ultimoCambio: null }, + ], + conteos: { leadsNuevosSemana: 1, actividadesAbiertas: 0 }, + } +} + +describe('unirPendientesLocales (Monday vivo)', () => { + it('agrega los items loc_* a la lista de su board con pendienteSync y re-ordena', () => { + const pendientes = [ + fila({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', updatedAt: new Date('2026-09-05T11:00:00') }), + fila({ leadId: 'loc_n2', board: 'OPORTUNIDADES', etapa: 'Afluencia', updatedAt: new Date('2026-09-05T10:00:00') }), + fila({ leadId: 'loc_n3', board: 'APARTADO', etapa: 'Pago Apartado' }), + fila({ leadId: 'loc_n4', board: 'LEADS', etapa: 'Descartado' }), // terminal: no se agrega + ] + const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala') + + // loc_n1 es más reciente que el de Monday: queda primero + expect(r.misLeads.map((l) => l.id)).toEqual(['loc_n1', 'loc_n2', '9001']) + expect(r.misLeads[0]).toMatchObject({ pendienteSync: true, fuente: 'lead' }) + expect(r.misLeads[1]).toMatchObject({ pendienteSync: true, fuente: 'oportunidad' }) + expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['loc_n2', '9002']) + expect(r.misOportunidades.porEtapa).toEqual({ Afluencia: 2 }) + expect(r.misApartados.map((a) => a.id)).toEqual(['9003', 'loc_n3']) // sin fecha límite al final + expect(r.misApartados[1]).toMatchObject({ pendienteSync: true }) + }) + + it('fila con mondayItemId (cambio pendiente sobre item Monday): no duplica, solo marca el existente', () => { + const pendientes = [ + fila({ leadId: '9001', board: 'LEADS', etapa: 'Contactado', mondayItemId: '9001' }), + fila({ leadId: '9003', board: 'APARTADO', etapa: 'Documentos', mondayItemId: '9003' }), + ] + const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala') + + expect(r.misLeads).toHaveLength(1) + expect(r.misLeads[0]).toMatchObject({ id: '9001', pendienteSync: true }) + expect(r.misApartados).toHaveLength(1) + expect(r.misApartados[0]).toMatchObject({ id: '9003', pendienteSync: true }) + }) + + it('marca Mis capturas y rellena el estado local cuando Monday aún no lo tiene', () => { + const pendientes = [fila({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', origen: 'WhatsApp' })] + const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala') + + const captura = r.misCapturas.find((c) => c.leadId === 'loc_n1') + expect(captura).toMatchObject({ pendienteSync: true, estado: 'NUEVO', origen: 'WhatsApp' }) + // La captura ya conocida por Monday conserva su estado + expect(r.misCapturas.find((c) => c.leadId === '9001')?.estado).toBe('Nuevo') + }) +}) diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index ace6ee0..94c570e 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -52,6 +52,8 @@ export interface LeadMiDia { fuente: 'lead' | 'oportunidad' /** Último evento del activity log del board; null si el item no tiene eventos */ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null + /** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */ + pendienteSync?: boolean } export interface ActividadMiDia { @@ -78,6 +80,8 @@ export interface CapturaMiDia { estado: string // estado ACTUAL en Monday; '' si el lead ya no existe en el board origen: string capturadoEn: string // ISO + /** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */ + pendienteSync?: boolean } export interface ClienteMiDia { @@ -104,6 +108,8 @@ export interface OportunidadMiDia { updatedAt: string /** Último evento del activity log del board; null si el item no tiene eventos */ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null + /** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */ + pendienteSync?: boolean } /** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */ @@ -115,6 +121,8 @@ export interface ApartadoMiDia { fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene) /** Último evento del activity log del board; null si el item no tiene eventos */ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null + /** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */ + pendienteSync?: boolean } export interface ResumenMiDia { @@ -499,3 +507,291 @@ export async function resumenMiDia( return resumen } + + +// ---------- Modo local (Monday caído) y unión durante la transición ---------- + +/** + * Fila local del embudo (tabla LeadCaptura) tal como la necesita Mi día. El + * controller pasa las filas de Prisma ya filtradas por el usuario; la forma es + * estructural para que los tests usen fixtures llanas. + */ +export interface FilaLocalMiDia { + leadId: string + nombreLead: string + telefono: string | null + email: string | null + origen: string | null + desarrollo: string | null + modelo: string | null + board: string // LEADS | OPORTUNIDADES | APARTADO + etapa: string + /** Item de Monday una vez sincronizado (null en capturas puramente locales) */ + mondayItemId: string | null + pendienteSync: boolean + createdAt: Date + updatedAt: Date +} + +/** Cliente local tal como lo necesita el modo local (misma forma que en resumenMiDia) */ +export interface ClienteLocalMiDia { + id: string + nombreCompleto: string + proyecto: string + unidadCodigo: string | null + mondayApartadoId: string | null + createdAt: Date +} + +/** "Último cambio" derivado del updatedAt local; el quién es el capturista ('app' si no hay nombre) */ +function ultimoCambioLocal(fila: FilaLocalMiDia, nombreUsuario: string) { + return { + fechaISO: fila.updatedAt.toISOString(), + usuario: nombreUsuario.trim() !== '' ? nombreUsuario : 'app', + detalle: 'Actualización', + } +} + +const porUpdatedAtDesc = (a: { updatedAt: string }, b: { updatedAt: string }): number => + b.updatedAt.localeCompare(a.updatedAt) + +/** + * ResumenMiDia COMPLETO construido solo con datos locales (Monday caído). + * Misma forma que resumenMiDia: Mis leads (board LEADS, etapa no terminal), + * Mis oportunidades (board OPORTUNIDADES), Mis apartados (board APARTADO + + * unidad ligada vía Cliente), Mis capturas, Mis clientes. Lo que no existe en + * local va vacío/cero sin romper el front: actividades del día (no hay tabla + * local), apartadosPorVencer (los apartados locales aún no guardan fecha + * límite) y la sección equipo (se omite). + */ +export function resumenMiDiaLocal( + usuario: UsuarioMiDia, + capturas: FilaLocalMiDia[], + clientes: ClienteLocalMiDia[], + ahora: Date = new Date(), +): ResumenMiDia { + const hace7DiasMs = ahora.getTime() - 7 * MS_DIA + + const leadsLocales = capturas.filter((c) => c.board === 'LEADS') + const oportunidadesLocales = capturas.filter((c) => c.board === 'OPORTUNIDADES') + const apartadosLocales = capturas.filter((c) => c.board === 'APARTADO') + + // "Mis leads" mezcla Leads + Oportunidades, igual que el camino Monday + const misLeads: LeadMiDia[] = [ + ...leadsLocales + .filter((c) => !esEstadoTerminalLead(c.etapa)) + .map((c) => ({ + id: c.leadId, + nombre: c.nombreLead, + telefono: c.telefono ?? '', + email: c.email ?? '', + estado: c.etapa, + origen: c.origen ?? '', + updatedAt: c.updatedAt.toISOString(), + fuente: 'lead' as const, + ultimoCambio: ultimoCambioLocal(c, usuario.nombre), + pendienteSync: c.pendienteSync, + })), + ...oportunidadesLocales + .filter((c) => !esEtapaTerminal(c.etapa)) + .map((c) => ({ + id: c.leadId, + nombre: c.nombreLead, + telefono: c.telefono ?? '', + email: c.email ?? '', + estado: c.etapa, + origen: c.origen ?? '', + updatedAt: c.updatedAt.toISOString(), + fuente: 'oportunidad' as const, + ultimoCambio: ultimoCambioLocal(c, usuario.nombre), + pendienteSync: c.pendienteSync, + })), + ].sort(porUpdatedAtDesc) + + // Conteo por etapa de TODAS las oportunidades (incluye terminales, como Monday) + const porEtapa: Record = {} + for (const o of oportunidadesLocales) { + const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim() + porEtapa[etapa] = (porEtapa[etapa] ?? 0) + 1 + } + + const itemsOportunidades: OportunidadMiDia[] = oportunidadesLocales + .filter((c) => !esEtapaTerminal(c.etapa)) + .map((c) => ({ + id: c.leadId, + nombre: c.nombreLead, + telefono: c.telefono ?? '', + email: c.email ?? '', + etapa: c.etapa, + desarrollo: c.desarrollo ?? '', + modeloInteres: c.modelo ?? '', + unidadCodigo: '', + updatedAt: c.updatedAt.toISOString(), + ultimoCambio: ultimoCambioLocal(c, usuario.nombre), + pendienteSync: c.pendienteSync, + })) + .sort(porUpdatedAtDesc) + + // La unidad del apartado local se conoce vía el Cliente ligado (alta iniciada) + const unidadPorApartadoId = new Map( + clientes + .filter((c) => c.mondayApartadoId !== null) + .map((c) => [c.mondayApartadoId!, c.unidadCodigo ?? '']), + ) + const misApartados: ApartadoMiDia[] = apartadosLocales + .filter((c) => !esEstadoFinalApartado(c.etapa)) + .map((c) => ({ + id: c.leadId, + nombre: c.nombreLead, + unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '', + estado: c.etapa, + fechaLimite: '', + ultimoCambio: ultimoCambioLocal(c, usuario.nombre), + pendienteSync: c.pendienteSync, + })) + + const misCapturas: CapturaMiDia[] = capturas + .map((c) => ({ + leadId: c.leadId, + nombre: c.nombreLead, + telefono: c.telefono ?? '', + estado: c.etapa, + origen: c.origen ?? '', + capturadoEn: c.createdAt.toISOString(), + pendienteSync: c.pendienteSync, + })) + .sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn)) + + const etapaApartadoPorId = new Map(apartadosLocales.map((a) => [a.leadId, a.etapa])) + const misClientes: ClienteMiDia[] = clientes + .map((c) => ({ + id: c.id, + nombre: c.nombreCompleto, + proyecto: c.proyecto, + unidadCodigo: c.unidadCodigo ?? '', + completado: c.proyecto.trim() !== '', + capturadoEn: c.createdAt.toISOString(), + estadoApartado: + c.mondayApartadoId != null ? (etapaApartadoPorId.get(c.mondayApartadoId) ?? null) : null, + })) + .sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn)) + + return { + generadoEn: ahora.toISOString(), + sinMapeoMonday: usuario.mondayUserId === null, + misLeads, + misCapturas, + misClientes, + misActividadesHoy: [], // no hay tabla local de actividades + apartadosPorVencer: [], // los apartados locales aún no guardan fecha límite + misOportunidades: { porEtapa, items: itemsOportunidades }, + misApartados, + conteos: { + leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter( + (c) => c.createdAt.getTime() >= hace7DiasMs, + ).length, + actividadesAbiertas: 0, + }, + } +} + +/** + * Unión durante la transición (Monday vivo): agrega a las listas del resumen + * los items locales con pendienteSync=true que Monday aún no conoce (ids + * loc_*), con el flag pendienteSync para el badge ⏳ del front. Si la fila + * local ya tiene mondayItemId (cambio encolado sobre un item de Monday), no se + * duplica: solo se marca el item existente. También marca/rellena Mis capturas + * (estado local cuando Monday aún no lo tiene). Muta y devuelve `resumen`. + */ +export function unirPendientesLocales( + resumen: ResumenMiDia, + pendientes: FilaLocalMiDia[], + nombreUsuario: string, +): ResumenMiDia { + const leadsPorId = new Map(resumen.misLeads.map((l) => [l.id, l])) + const oportunidadesPorId = new Map(resumen.misOportunidades.items.map((o) => [o.id, o])) + const apartadosPorId = new Map(resumen.misApartados.map((a) => [a.id, a])) + const capturasPorId = new Map(resumen.misCapturas.map((c) => [c.leadId, c])) + + for (const p of pendientes) { + // Mis capturas: flag + estado/origen local cuando Monday aún no lo refleja + const captura = capturasPorId.get(p.leadId) + if (captura) { + captura.pendienteSync = true + if (captura.estado === '') captura.estado = p.etapa + if (captura.origen === '') captura.origen = p.origen ?? '' + } + + if (p.mondayItemId !== null) { + // Cambio pendiente sobre un item que Monday ya muestra: solo se marca + const existente = + leadsPorId.get(p.mondayItemId) ?? + oportunidadesPorId.get(p.mondayItemId) ?? + apartadosPorId.get(p.mondayItemId) + if (existente) existente.pendienteSync = true + continue + } + + // Item puramente local (loc_*): se agrega a la lista de su board + if (p.board === 'LEADS' && !esEstadoTerminalLead(p.etapa)) { + resumen.misLeads.push({ + id: p.leadId, + nombre: p.nombreLead, + telefono: p.telefono ?? '', + email: p.email ?? '', + estado: p.etapa, + origen: p.origen ?? '', + updatedAt: p.updatedAt.toISOString(), + fuente: 'lead', + ultimoCambio: ultimoCambioLocal(p, nombreUsuario), + pendienteSync: true, + }) + } else if (p.board === 'OPORTUNIDADES') { + // porEtapa cuenta TODAS las oportunidades (incluye terminales, como Monday) + const etapa = p.etapa.trim() === '' ? '(sin etapa)' : p.etapa.trim() + resumen.misOportunidades.porEtapa[etapa] = (resumen.misOportunidades.porEtapa[etapa] ?? 0) + 1 + if (!esEtapaTerminal(p.etapa)) { + resumen.misLeads.push({ + id: p.leadId, + nombre: p.nombreLead, + telefono: p.telefono ?? '', + email: p.email ?? '', + estado: p.etapa, + origen: p.origen ?? '', + updatedAt: p.updatedAt.toISOString(), + fuente: 'oportunidad', + ultimoCambio: ultimoCambioLocal(p, nombreUsuario), + pendienteSync: true, + }) + resumen.misOportunidades.items.push({ + id: p.leadId, + nombre: p.nombreLead, + telefono: p.telefono ?? '', + email: p.email ?? '', + etapa: p.etapa, + desarrollo: p.desarrollo ?? '', + modeloInteres: p.modelo ?? '', + unidadCodigo: '', + updatedAt: p.updatedAt.toISOString(), + ultimoCambio: ultimoCambioLocal(p, nombreUsuario), + pendienteSync: true, + }) + } + } else if (p.board === 'APARTADO' && !esEstadoFinalApartado(p.etapa)) { + resumen.misApartados.push({ + id: p.leadId, + nombre: p.nombreLead, + unidadCodigo: '', + estado: p.etapa, + fechaLimite: '', + ultimoCambio: ultimoCambioLocal(p, nombreUsuario), + pendienteSync: true, + }) + } + } + + resumen.misLeads.sort(porUpdatedAtDesc) + resumen.misOportunidades.items.sort(porUpdatedAtDesc) + resumen.misApartados.sort((a, b) => (a.fechaLimite || '9999-12-31').localeCompare(b.fechaLimite || '9999-12-31')) + return resumen +} diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index 40e2f8c..10a2fa3 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -1,6 +1,8 @@ import { config } from '../config/index.js' import type { UnidadCotizable } from './cotizacion.service.js' import { AppError } from '../utils/errors.js' +import { prisma } from './prisma.js' +import { saludMonday } from './salud-monday.service.js' /** Unidad tal como la consume el API (y el frontend del cotizador) */ export type Unidad = UnidadCotizable @@ -230,12 +232,19 @@ export class MondayService { for (let intento = 0; intento <= this.maxReintentos; intento++) { if (intento > 0) await sleep(this.baseDelayMs * 2 ** (intento - 1)) try { - return await this.graphqlOnce(query, variables) + const data = await this.graphqlOnce(query, variables) + saludMonday.registrarExito() + return data } catch (err) { - if (!(err instanceof MondayRetryableError)) throw err + if (!(err instanceof MondayRetryableError)) { + // Fallo definitivo (AppError 502 de Monday): alimenta el circuit breaker + saludMonday.registrarFallo() + throw err + } ultimoError = err } } + saludMonday.registrarFallo() throw new AppError(502, `Monday no respondió tras ${this.maxReintentos + 1} intentos: ${ultimoError?.message}`) } @@ -363,9 +372,45 @@ export class MondayService { unidades.push(unidad) } this.unidadesCache = { unidades, itemIds, expira: ahora + CACHE_UNIDADES_MS } + // Write-through al espejo local (tabla Unidad): mantiene fresco el + // inventario para el modo local. Best-effort: nunca rompe la lectura. + void this.persistirUnidadesLocal(unidades) return unidades } + /** + * Upsert best-effort de las unidades leídas de Monday a la tabla local + * `Unidad` (fallback del cotizador cuando Monday cae). Protegido para poder + * apagarlo en tests; los errores solo se loguean. + */ + protected async persistirUnidadesLocal(unidades: Unidad[]): Promise { + // Los tests mockean fetch y no deben escribir en la base real + if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return + try { + for (const u of unidades) { + const datos = { + desarrollo: u.desarrollo, + modelo: u.modelo, + supConstruccion: u.supConstruccion, + supExcedente: u.supExcedente, + supEstacionamiento: u.supEstacionamiento, + precioLista: u.precioSinAcabados, + precioAcabados: u.precioConAcabados, + status: u.status, + etapa: u.etapa ?? '', + } + await prisma.unidad.upsert({ + where: { codigo: u.codigo }, + update: datos, + create: { codigo: u.codigo, ...datos }, + }) + } + } catch (err) { + // eslint-disable-next-line no-console + console.error('[unidades] falló el write-through local (se ignora):', err) + } + } + /** Una unidad por código (U-101, U-310...); null si no existe */ async getUnidad(codigo: string): Promise { const unidades = await this.getUnidades() @@ -724,19 +769,60 @@ export class MondayService { } async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise { - const boardId = config.monday.boards.unidades - // Asegura cache fresco para resolver el itemId - await this.getUnidades() - const itemId = this.unidadesCache?.itemIds.get(codigo) - if (!itemId) throw new AppError(404, `Unidad ${codigo} no encontrada en Monday`) - const col = await this.columnaId(boardId, 'Disponibilidad') - await this.graphql( - `mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) { - change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id } - }`, - { boardId, itemId, columnId: col.id, value: JSON.stringify({ label: status }) }, - ) - this.invalidarCacheUnidades() + // Modo local: Monday caído (circuit breaker) → espejo local + réplica encolada + if (!saludMonday.estaDisponible()) { + await this.actualizarDisponibilidadUnidadLocal(codigo, status) + return + } + try { + const boardId = config.monday.boards.unidades + // Asegura cache fresco para resolver el itemId + await this.getUnidades() + const itemId = this.unidadesCache?.itemIds.get(codigo) + if (!itemId) throw new AppError(404, `Unidad ${codigo} no encontrada en Monday`) + const col = await this.columnaId(boardId, 'Disponibilidad') + await this.graphql( + `mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) { + change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id } + }`, + { boardId, itemId, columnId: col.id, value: JSON.stringify({ label: status }) }, + ) + this.invalidarCacheUnidades() + } catch (err) { + // Monday se cayó a media operación: mismo fallback local (el 404 de + // "no existe en Monday" NO es fallo de Monday y se propaga intacto) + if (err instanceof AppError && err.status === 502) { + await this.actualizarDisponibilidadUnidadLocal(codigo, status) + return + } + throw err + } + } + + /** + * Fallback local de actualizarDisponibilidadUnidad: actualiza el status en el + * espejo (tabla Unidad) y encola la réplica DISPONIBILIDAD_UNIDAD para que el + * sync worker la aplique en Monday cuando vuelva. 404 si la unidad no está en + * el espejo. El encolado es best-effort (nunca rompe la escritura local). + */ + private async actualizarDisponibilidadUnidadLocal( + codigo: string, + status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA', + ): Promise { + const resultado = await prisma.unidad.updateMany({ where: { codigo }, data: { status } }) + if (resultado.count === 0) throw new AppError(404, `Unidad ${codigo} no encontrada`) + try { + await prisma.syncPendiente.create({ + data: { + tipo: 'DISPONIBILIDAD_UNIDAD', + payloadJson: JSON.stringify({ codigo, status }), + referenciaLocal: codigo, + }, + }) + } catch (err) { + // eslint-disable-next-line no-console + console.error(`[sync] no se pudo encolar DISPONIBILIDAD_UNIDAD de ${codigo}:`, err) + } } // ---------- Escrituras genéricas ---------- diff --git a/api-gateway/src/services/reportes-local.test.ts b/api-gateway/src/services/reportes-local.test.ts new file mode 100644 index 0000000..c46ed51 --- /dev/null +++ b/api-gateway/src/services/reportes-local.test.ts @@ -0,0 +1,271 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PlanPago } from './cobranza.service.js' +import type { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js' +import { AppError } from '../utils/errors.js' + +// ---------- Prisma mockeado (los tests no tocan la BD) ---------- + +const prismaMock = vi.hoisted(() => ({ + leadCaptura: { + findMany: vi.fn(), + }, + unidad: { + findMany: vi.fn(), + }, +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) + +import { cargarDatosLocalesPrisma, ReportesService, type DatosLocalesReportes } from './reportes.service.js' + +/** + * Fallback de reportes (modo local): Monday caído o 502 → el resumen se + * calcula desde tablas locales (LeadCaptura por board, Unidad por status, + * PlanPago como siempre) con el flag aditivo `parcial: true`. + */ + +function pago(id: string, fechaVencimiento: string, monto: number, montoPagado = 0): PlanPago['pagos'][number] { + return { id, numPago: 1, fechaVencimiento, monto, montoPagado, aplicaciones: [] } +} + +function plan(id: string, overrides: Partial = {}): PlanPago { + return { + id, + clienteNombre: `Cliente ${id}`, + unidadCodigo: 'U-101', + proyecto: 'ATELIER', + fechaInicio: '2026-07-01', + engancheTotal: 100000, + numPagos: 2, + mensualidad: 50000, + status: 'ACTIVO', + pagos: [pago(`p-${id}`, '2027-01-01', 50000)], // pendiente y al corriente: ACTIVO + notas: [], + createdAt: '2026-07-01T00:00:00.000Z', + ...overrides, + } +} + +function leadMonday(id: string, estado = 'Nuevo'): LeadMonday { + return { + id, + nombre: `Lead ${id}`, + createdAt: '2026-09-04T10:00:00.000Z', + updatedAt: '2026-09-04T10:00:00.000Z', + estado, + origen: 'Web', + telefono: '', + email: '', + vendedorIds: [], + } +} + +function unidadMonday(codigo: string, status = 'DISPONIBLE'): Unidad { + return { + codigo, + modelo: '1R', + fechaEntrega: null, + supEstacionamiento: 0, + supConstruccion: 60, + supExcedente: 0, + precioSinAcabados: 1_000_000, + precioConAcabados: 1_100_000, + status, + desarrollo: 'SENDA', + mesesParaFirma: null, + etapa: null, + } +} + +function mondayMock(overrides: Record = {}) { + return { + getLeads: vi.fn(async () => [leadMonday('m1')]), + getOportunidades: vi.fn(async () => [] as OportunidadMonday[]), + getUnidades: vi.fn(async () => [unidadMonday('M-1')]), + getActividadesAbiertas: vi.fn(async () => 3), + ...overrides, + } +} + +const datosLocales: DatosLocalesReportes = { + leads: [leadMonday('loc_l1'), leadMonday('loc_l2', 'Descartado')], + oportunidades: [ + { + id: 'loc_o1', + nombre: 'Op Local', + createdAt: '2026-09-04T10:00:00.000Z', + updatedAt: '2026-09-04T10:00:00.000Z', + etapa: 'Afluencia', + unidadCodigo: '', + precioFinal: 0, + asesorIds: [], + telefono: '', + email: '', + origen: 'WhatsApp', + desarrollo: 'SENDA', + modeloInteres: '1R', + credito: 'Infonavit', + }, + ], + unidades: [unidadMonday('U-1'), unidadMonday('U-2', 'APARTADO')], +} + +beforeEach(() => { + vi.clearAllMocks() + prismaMock.leadCaptura.findMany.mockResolvedValue([]) + prismaMock.unidad.findMany.mockResolvedValue([]) +}) + +describe('ReportesService (fallback local)', () => { + it('Monday caído (salud): resumen desde datos locales con parcial: true, sin llamar a Monday', async () => { + const monday = mondayMock() + const svc = new ReportesService(monday, async () => [plan('a')], { + salud: { estaDisponible: () => false }, + cargarDatosLocales: async () => datosLocales, + }) + + const r = await svc.resumen() + + expect(r.parcial).toBe(true) + expect(r.leads.total).toBe(2) + expect(r.leads.porEstado).toEqual({ Nuevo: 1, Descartado: 1 }) + expect(r.oportunidades.total).toBe(1) + expect(r.oportunidades.porEtapa).toEqual({ Afluencia: 1 }) + expect(r.unidades.total).toBe(2) + expect(r.unidades.disponibles).toBe(1) + expect(r.cobranza.planes.activos).toBe(1) // planes locales como siempre + expect(r.actividadesAbiertas).toBe(0) // no hay tabla local de actividades + expect(monday.getLeads).not.toHaveBeenCalled() + }) + + it('Monday vivo: camino normal intacto (sin parcial) y cache de 60 s', async () => { + const monday = mondayMock() + const cargarPlanes = vi.fn(async () => [plan('a')]) + let ahora = 1_000_000 + const svc = new ReportesService(monday, cargarPlanes, { + now: () => ahora, + salud: { estaDisponible: () => true }, + cargarDatosLocales: async () => datosLocales, + }) + + const r1 = await svc.resumen() + expect(r1.parcial).toBeUndefined() + expect(r1.leads.total).toBe(1) + expect(r1.actividadesAbiertas).toBe(3) + + ahora += 30_000 // dentro del TTL: segunda llamada sale del cache + const r2 = await svc.resumen() + expect(r2).toBe(r1) + expect(monday.getLeads).toHaveBeenCalledTimes(1) + expect(cargarPlanes).toHaveBeenCalledTimes(1) + }) + + it('Monday lanza AppError 502 a media lectura: cae al resumen local con parcial', async () => { + const monday = mondayMock({ getLeads: vi.fn(async () => { throw new AppError(502, 'Monday no respondió') }) }) + const svc = new ReportesService(monday, async () => [], { + salud: { estaDisponible: () => true }, + cargarDatosLocales: async () => datosLocales, + }) + + const r = await svc.resumen() + expect(r.parcial).toBe(true) + expect(r.leads.total).toBe(2) + }) + + it('otro error de Monday se propaga (no se traga nada)', async () => { + const monday = mondayMock({ getLeads: vi.fn(async () => { throw new AppError(500, 'bug') }) }) + const svc = new ReportesService(monday, async () => [], { + salud: { estaDisponible: () => true }, + cargarDatosLocales: async () => datosLocales, + }) + await expect(svc.resumen()).rejects.toThrow('bug') + }) +}) + +describe('cargarDatosLocalesPrisma (mapeo LeadCaptura/Unidad → DTOs Monday)', () => { + it('mapea boards a LeadMonday/OportunidadMonday con el perfil de datosJson y unidades al DTO', async () => { + prismaMock.leadCaptura.findMany.mockResolvedValue([ + { + leadId: 'loc_l1', + nombreLead: 'Lead Local', + telefono: '4771234567', + email: null, + origen: 'Web', + desarrollo: null, + modelo: null, + board: 'LEADS', + etapa: 'NUEVO', + datosJson: null, + createdAt: new Date('2026-09-04T10:00:00'), + updatedAt: new Date('2026-09-04T11:00:00'), + }, + { + leadId: 'loc_o1', + nombreLead: 'Op Local', + telefono: null, + email: 'op@correo.mx', + origen: 'WhatsApp', + desarrollo: 'SENDA', + modelo: '1R', + board: 'OPORTUNIDADES', + etapa: 'Afluencia', + datosJson: JSON.stringify({ estadoCivil: 'Soltero(a)', tipoEmpleo: 'Empleado', credito: 'Infonavit', edad: 35, califica: 'Si' }), + createdAt: new Date('2026-09-03T10:00:00'), + updatedAt: new Date('2026-09-03T11:00:00'), + }, + { + leadId: 'loc_a1', + nombreLead: 'Apt Local', + board: 'APARTADO', // el board APARTADO no alimenta leads ni oportunidades + etapa: 'Pago Apartado', + telefono: null, + email: null, + origen: null, + desarrollo: null, + modelo: null, + datosJson: null, + createdAt: new Date('2026-09-02T10:00:00'), + updatedAt: new Date('2026-09-02T11:00:00'), + }, + ]) + prismaMock.unidad.findMany.mockResolvedValue([ + { + codigo: 'SENDA-101', + desarrollo: 'SENDA', + modelo: '1R', + supConstruccion: 69.5, + supExcedente: 0, + supEstacionamiento: 12.5, + precioLista: 4_415_000, + precioAcabados: 4_579_000, + status: 'DISPONIBLE', + etapa: '', + }, + ]) + + const datos = await cargarDatosLocalesPrisma() + + expect(datos.leads).toHaveLength(1) + expect(datos.leads[0]).toMatchObject({ id: 'loc_l1', estado: 'NUEVO', origen: 'Web', telefono: '4771234567' }) + expect(datos.oportunidades).toHaveLength(1) + expect(datos.oportunidades[0]).toMatchObject({ + id: 'loc_o1', + etapa: 'Afluencia', + desarrollo: 'SENDA', + modeloInteres: '1R', + estadoCivil: 'Soltero(a)', + tipoEmpleo: 'Empleado', + credito: 'Infonavit', + edad: 35, + califica: 'Si', + }) + expect(datos.unidades).toHaveLength(1) + expect(datos.unidades[0]).toMatchObject({ + codigo: 'SENDA-101', + precioSinAcabados: 4_415_000, + precioConAcabados: 4_579_000, + status: 'DISPONIBLE', + etapa: null, + }) + }) +}) diff --git a/api-gateway/src/services/reportes.service.ts b/api-gateway/src/services/reportes.service.ts index 86da08e..a50bd0b 100644 --- a/api-gateway/src/services/reportes.service.ts +++ b/api-gateway/src/services/reportes.service.ts @@ -1,6 +1,10 @@ import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js' import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js' import { ETAPAS_PIPELINE } from './etapas.js' +import { prisma } from './prisma.js' +import { saludMonday, type SaludMondayService } from './salud-monday.service.js' +import { unidadLocalADto } from './unidades-local.service.js' +import { AppError } from '../utils/errors.js' /** * Agregador de reportes para la app de tableros (/dash/). @@ -73,6 +77,8 @@ export interface ResumenReportes { montoVencido: number } actividadesAbiertas: number + /** true cuando el resumen se calculó desde tablas locales (Monday caído): métricas parciales */ + parcial?: boolean } /** Datos crudos que necesita el agregador (inyectables en tests) */ @@ -285,16 +291,102 @@ type MondayReportes = Pick Promise +/** Datos del embudo/inventario leídos de tablas locales (modo degradado) */ +export interface DatosLocalesReportes { + leads: LeadMonday[] + oportunidades: OportunidadMonday[] + unidades: Unidad[] +} + +/** Cargador de los datos locales para el fallback (Prisma en prod, fixture en tests) */ +export type CargadorDatosLocales = () => Promise + export interface OpcionesReportes { /** Reloj inyectable para probar el cache (default Date.now) */ now?: () => number /** TTL del cache en ms (default 60 s) */ cacheMs?: number + /** Salud de Monday (default: singleton saludMonday) */ + salud?: Pick + /** Datos locales para el fallback (default: tablas LeadCaptura/Unidad vía Prisma) */ + cargarDatosLocales?: CargadorDatosLocales +} + +/** string de un campo del datosJson de afluencia ('' si no es string) */ +function campoTexto(datos: Record, campo: string): string { + const v = datos[campo] + return typeof v === 'string' ? v : '' +} + +/** + * Cargador local por defecto: leads/oportunidades desde LeadCaptura (board + + * etapa; el perfil de la afluencia va en datosJson) e inventario desde la + * tabla Unidad (espejo). Misma forma de DTO que las lecturas Monday. + */ +export async function cargarDatosLocalesPrisma(): Promise { + const capturas = await prisma.leadCaptura.findMany({ + where: { board: { in: ['LEADS', 'OPORTUNIDADES'] } }, + }) + const unidadesRows = await prisma.unidad.findMany() + + const leads: LeadMonday[] = capturas + .filter((c) => c.board === 'LEADS') + .map((c) => ({ + id: c.leadId, + nombre: c.nombreLead, + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), + estado: c.etapa, + origen: c.origen ?? '', + telefono: c.telefono ?? '', + email: c.email ?? '', + vendedorIds: [], + })) + const oportunidades: OportunidadMonday[] = capturas + .filter((c) => c.board === 'OPORTUNIDADES') + .map((c) => { + let datos: Record = {} + if (c.datosJson !== null) { + try { + datos = JSON.parse(c.datosJson) as Record + } catch { + datos = {} + } + } + const edad = datos['edad'] + return { + id: c.leadId, + nombre: c.nombreLead, + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), + etapa: c.etapa, + unidadCodigo: '', + precioFinal: 0, + asesorIds: [], + telefono: c.telefono ?? '', + email: c.email ?? '', + origen: c.origen ?? '', + desarrollo: c.desarrollo ?? '', + modeloInteres: c.modelo ?? '', + medioContacto: campoTexto(datos, 'medioContacto'), + edad: typeof edad === 'number' && Number.isFinite(edad) ? edad : null, + estadoCivil: campoTexto(datos, 'estadoCivil'), + tipoEmpleo: campoTexto(datos, 'tipoEmpleo'), + credito: campoTexto(datos, 'credito'), + tipoEconomia: campoTexto(datos, 'tipoEconomia'), + califica: campoTexto(datos, 'califica'), + rangoSalarial: campoTexto(datos, 'rangoSalarial'), + loQuierePara: campoTexto(datos, 'loQuierePara'), + } + }) + return { leads, oportunidades, unidades: unidadesRows.map(unidadLocalADto) } } export class ReportesService { private readonly now: () => number private readonly cacheMs: number + private readonly salud: Pick + private readonly cargarDatosLocales: CargadorDatosLocales private cache: { resumen: ResumenReportes; expira: number } | null = null constructor( @@ -304,6 +396,8 @@ export class ReportesService { ) { this.now = opciones.now ?? Date.now this.cacheMs = opciones.cacheMs ?? CACHE_REPORTES_MS + this.salud = opciones.salud ?? saludMonday + this.cargarDatosLocales = opciones.cargarDatosLocales ?? cargarDatosLocalesPrisma } /** Invalida el cache (útil tras escrituras que cambian los números) */ @@ -315,16 +409,35 @@ export class ReportesService { const ahoraMs = this.now() if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen - // Las fuentes de Monday se piden en paralelo; Prisma va por su lado - const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([ - this.monday.getLeads(), - this.monday.getOportunidades(), - this.monday.getUnidades(), - this.monday.getActividadesAbiertas(), - this.cargarPlanes(), - ]) - const resumen = construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs)) + const resumen = await this.cargarResumen(ahoraMs) this.cache = { resumen, expira: ahoraMs + this.cacheMs } return resumen } + + /** + * Camino normal: Monday + planes locales en paralelo (con el cache de 60 s + * de siempre). Camino degradado: Monday caído (circuit breaker) o AppError + * 502 a media lectura → resumen desde tablas locales con `parcial: true`. + */ + private async cargarResumen(ahoraMs: number): Promise { + if (this.salud.estaDisponible()) { + try { + // Las fuentes de Monday se piden en paralelo; Prisma va por su lado + const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([ + this.monday.getLeads(), + this.monday.getOportunidades(), + this.monday.getUnidades(), + this.monday.getActividadesAbiertas(), + this.cargarPlanes(), + ]) + return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs)) + } catch (err) { + if (!(err instanceof AppError && err.status === 502)) throw err + // Monday se cayó a media lectura: cae al camino local + } + } + const [locales, planes] = await Promise.all([this.cargarDatosLocales(), this.cargarPlanes()]) + const resumen = construirResumen({ ...locales, planes, actividadesAbiertas: 0 }, new Date(ahoraMs)) + return { ...resumen, parcial: true } + } } diff --git a/api-gateway/src/services/salud-monday.service.test.ts b/api-gateway/src/services/salud-monday.service.test.ts new file mode 100644 index 0000000..527970a --- /dev/null +++ b/api-gateway/src/services/salud-monday.service.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { FALLOS_PARA_CAER, SaludMondayService } from './salud-monday.service.js' + +describe('SaludMondayService', () => { + afterEach(() => { + delete process.env.MODO_LOCAL_FORZADO + }) + + it('arranca disponible (optimista)', () => { + const salud = new SaludMondayService() + expect(salud.ok()).toBe(true) + expect(salud.estaDisponible()).toBe(true) + }) + + it('cae tras FALLOS_PARA_CAER fallos consecutivos', () => { + const salud = new SaludMondayService() + for (let i = 0; i < FALLOS_PARA_CAER - 1; i++) salud.registrarFallo() + expect(salud.estaDisponible()).toBe(true) // aún aguanta + salud.registrarFallo() + expect(salud.ok()).toBe(false) + expect(salud.estaDisponible()).toBe(false) + }) + + it('un éxito resetea el contador de fallos', () => { + const salud = new SaludMondayService() + salud.registrarFallo() + salud.registrarFallo() + salud.registrarExito() + salud.registrarFallo() + salud.registrarFallo() + expect(salud.estaDisponible()).toBe(true) // nunca llegó a 3 seguidos + }) + + it('un éxito recupera el flag una vez caído', () => { + const salud = new SaludMondayService() + for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo() + expect(salud.ok()).toBe(false) + salud.registrarExito() + expect(salud.estaDisponible()).toBe(true) + }) + + it('MODO_LOCAL_FORZADO=true fuerza modo local aunque Monday esté sano', () => { + const salud = new SaludMondayService() + process.env.MODO_LOCAL_FORZADO = 'true' + expect(salud.ok()).toBe(true) // el flag crudo no cambia + expect(salud.estaDisponible()).toBe(false) + process.env.MODO_LOCAL_FORZADO = 'false' + expect(salud.estaDisponible()).toBe(true) + }) + + it('sondear() con ping exitoso recupera el flag y devuelve true', async () => { + const salud = new SaludMondayService() + for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo() + salud.registrarPing(async () => true) + await expect(salud.sondear()).resolves.toBe(true) + expect(salud.estaDisponible()).toBe(true) + }) + + it('sondear() con ping fallido deja el flag caído', async () => { + const salud = new SaludMondayService() + for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo() + salud.registrarPing(async () => false) + await expect(salud.sondear()).resolves.toBe(false) + expect(salud.ok()).toBe(false) + }) + + it('sondear() sin ping registrado devuelve el flag actual', async () => { + const salud = new SaludMondayService() + await expect(salud.sondear()).resolves.toBe(true) + for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo() + await expect(salud.sondear()).resolves.toBe(false) + }) +}) diff --git a/api-gateway/src/services/salud-monday.service.ts b/api-gateway/src/services/salud-monday.service.ts new file mode 100644 index 0000000..77621c3 --- /dev/null +++ b/api-gateway/src/services/salud-monday.service.ts @@ -0,0 +1,73 @@ +/** + * Salud de Monday (circuit breaker ligero). + * + * Estado en memoria: `mondayOk` arranca en true (optimista) y pasa a false tras + * FALLOS_PARA_CAER fallos GraphQL consecutivos reportados por monday.service. + * Mientras está caído, los callers deben saltarse Monday de inmediato (modo + * local) en lugar de pagar los reintentos por request; un cron sondea cada 60 s + * y al recuperarse dispara el sync worker. + * + * MODO_LOCAL_FORZADO=true fuerza modo local aunque Monday responda (pruebas). + * + * El ping se inyecta con `registrarPing()` (wiring en index.ts) para no + * importar monday.service aquí y evitar ciclos de imports. + */ + +/** Fallos GraphQL consecutivos que tumban el flag */ +export const FALLOS_PARA_CAER = 3 + +export type PingMonday = () => Promise + +export class SaludMondayService { + private mondayOk = true + private fallosConsecutivos = 0 + private pingFn: PingMonday | null = null + + /** Registra el callback de ping (en index.ts: () => mondayService.ping()) */ + registrarPing(fn: PingMonday): void { + this.pingFn = fn + } + + /** Llamar tras cada llamada GraphQL exitosa */ + registrarExito(): void { + this.fallosConsecutivos = 0 + this.mondayOk = true + } + + /** Llamar tras cada fallo definitivo de Monday (AppError 502 de graphql()) */ + registrarFallo(): void { + this.fallosConsecutivos += 1 + if (this.fallosConsecutivos >= FALLOS_PARA_CAER) this.mondayOk = false + } + + /** Flag crudo (sin el override MODO_LOCAL_FORZADO); útil para el cron de sondeo */ + ok(): boolean { + return this.mondayOk + } + + /** true si los callers pueden hablar con Monday ahora mismo */ + estaDisponible(): boolean { + return this.mondayOk && process.env.MODO_LOCAL_FORZADO !== 'true' + } + + /** + * Ping a Monday. Si responde, recupera el flag y devuelve true (el caller + * decide si dispara el sync). Sin ping registrado devuelve el flag actual. + */ + async sondear(): Promise { + if (this.pingFn === null) return this.mondayOk + const vivo = await this.pingFn() + if (vivo) this.registrarExito() + return vivo + } + + /** Reset completo (tests) */ + restablecer(): void { + this.mondayOk = true + this.fallosConsecutivos = 0 + this.pingFn = null + } +} + +/** Singleton compartido por monday.service, controllers y crons */ +export const saludMonday = new SaludMondayService() diff --git a/api-gateway/src/services/sync-handlers.test.ts b/api-gateway/src/services/sync-handlers.test.ts new file mode 100644 index 0000000..7934e34 --- /dev/null +++ b/api-gateway/src/services/sync-handlers.test.ts @@ -0,0 +1,427 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// ---------- Prisma y Funnel mockeados (los tests no tocan BD ni red) ---------- + +const prismaMock = vi.hoisted(() => ({ + leadCaptura: { + findUnique: vi.fn(), + updateMany: vi.fn(), + }, + leadOportunidad: { + create: vi.fn(), + findUnique: vi.fn(), + }, + cliente: { + findUnique: vi.fn(), + updateMany: vi.fn(), + }, + cotizacion: { + updateMany: vi.fn(), + }, + usuario: { + findUnique: vi.fn(), + }, + automatizacionState: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, +})) + +const funnelMock = vi.hoisted(() => ({ + oportunidadPorTelefono: vi.fn(), +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) +vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock })) + +import type { StateStore } from './automatizaciones.service.js' +import type { MondayService } from './monday.service.js' +import { registrarHandlersSync } from './sync-handlers.js' +import { SyncMondayService, type SyncRegistro, type SyncStore } from './sync-monday.service.js' + +// ---------- Fixtures (mismo patrón que sync-monday.service.test.ts) ---------- + +let consecutivo = 0 +function registro(parcial: Partial = {}): SyncRegistro { + consecutivo += 1 + return { + id: parcial.id ?? `reg-${consecutivo}`, + tipo: 'CREAR_LEAD', + payloadJson: '{}', + referenciaLocal: null, + estado: 'PENDIENTE', + intentos: 0, + createdAt: new Date(Date.UTC(2026, 8, 5, 12, 0, consecutivo)), + ...parcial, + } +} + +function stateEnMemoria(): StateStore & { mapa: Map } { + const mapa = new Map() + return { + mapa, + async get(clave) { + return mapa.get(clave) ?? null + }, + async set(clave, valor) { + mapa.set(clave, valor) + }, + } +} + +function storeEnMemoria(registros: SyncRegistro[]) { + const rows = new Map(registros.map((r) => [r.id, { ...r }])) + const store: SyncStore & { rows: Map; ultimo: SyncRegistro } = { + rows, + ultimo: registros[registros.length - 1]!, + async pendientes(limite) { + return [...rows.values()] + .filter((r) => r.estado === 'PENDIENTE') + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .slice(0, limite) + }, + async marcar(id, datos) { + const row = rows.get(id) + if (!row) throw new Error(`Registro inexistente: ${id}`) + row.estado = datos.estado + if (datos.intentos !== undefined) row.intentos = datos.intentos + if (datos.ultimoError !== undefined) row.ultimoError = datos.ultimoError + }, + } + return store +} + +/** Monday mockeado: registra llamadas y devuelve ids controlados */ +function mondayMock() { + return { + cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[], + leadsCreados: [] as Record[], + oportunidadesCreadas: [] as Record[], + contactosCreados: [] as Record[], + apartadosCreados: [] as Record[], + motivos: [] as Record[], + asignaciones: [] as { itemId: string; userId: number }[], + async crearLead(datos: Record) { + this.leadsCreados.push(datos) + return 'monday-lead-1' + }, + async crearOportunidadAfluencia(datos: Record) { + this.oportunidadesCreadas.push(datos) + return 'monday-op-1' + }, + async actualizarOportunidadAfluencia(_id: string, _datos: Record) {}, + async crearContacto(datos: Record) { + this.contactosCreados.push(datos) + return 'monday-cont-1' + }, + async actualizarContacto(_id: string, _datos: Record) {}, + async crearApartado(datos: Record) { + this.apartadosCreados.push(datos) + return 'monday-apt-1' + }, + async crearOportunidad(datos: Record) { + this.oportunidadesCreadas.push(datos) + return 'monday-op-cot-1' + }, + async actualizarOportunidadCotizacion(_id: string, _datos: Record) {}, + async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: unknown) { + this.cambios.push({ boardId, itemId, columna, valor }) + }, + async crearMotivoNoCompra(datos: Record) { + this.motivos.push(datos) + return 'monday-motivo-1' + }, + async asignarVendedorALead(itemId: string, userId: number) { + this.asignaciones.push({ itemId, userId }) + }, + async asignarAsesorAOportunidad(itemId: string, userId: number) { + this.asignaciones.push({ itemId, userId }) + }, + } as unknown as MondayService & { + cambios: { boardId: string; itemId: string; columna: string; valor: unknown }[] + leadsCreados: Record[] + oportunidadesCreadas: Record[] + contactosCreados: Record[] + apartadosCreados: Record[] + motivos: Record[] + asignaciones: { itemId: string; userId: number }[] + } +} + +const saludSana = { estaDisponible: () => true } + +function crearServicio(monday: MondayService, store: SyncStore, state: StateStore) { + const svc = new SyncMondayService(monday, state, store, saludSana) + registrarHandlersSync(svc) + return svc +} + +beforeEach(() => { + vi.clearAllMocks() + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + prismaMock.leadCaptura.updateMany.mockResolvedValue({}) + prismaMock.leadOportunidad.create.mockResolvedValue({}) + prismaMock.leadOportunidad.findUnique.mockResolvedValue(null) + prismaMock.cliente.findUnique.mockResolvedValue(null) + prismaMock.cliente.updateMany.mockResolvedValue({}) + prismaMock.cotizacion.updateMany.mockResolvedValue({}) + prismaMock.usuario.findUnique.mockResolvedValue(null) + prismaMock.automatizacionState.upsert.mockResolvedValue({}) + funnelMock.oportunidadPorTelefono.mockResolvedValue(null) +}) + +describe('handler CREAR_LEAD', () => { + it('crea el lead en Monday y actualiza la fila local (mondayItemId + pendienteSync=false)', async () => { + const monday = mondayMock() + const store = storeEnMemoria([ + registro({ + tipo: 'CREAR_LEAD', + payloadJson: JSON.stringify({ datos: { nombre: 'Ana Prueba', telefono: '4771234567' } }), + referenciaLocal: 'loc_ana', + }), + ]) + const svc = crearServicio(monday, store, stateEnMemoria()) + const r = await svc.procesarPendientes() + expect(r).toEqual({ procesados: 1, fallos: 0 }) + expect(monday.leadsCreados).toEqual([{ nombre: 'Ana Prueba', telefono: '4771234567' }]) + expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({ + where: { leadId: 'loc_ana' }, + data: { mondayItemId: 'monday-lead-1', pendienteSync: false }, + }) + expect(store.rows.get(store.ultimo.id)?.estado).toBe('HECHO') + }) +}) + +describe('handler CONVERSION', () => { + const payloadConversion = { + oportunidadIdLocal: 'loc_op', + datos: { nombre: 'Ana Prueba', estadoLabel: 'Agendo Cita', telefono: '4771234567' }, + } + + it('sin mondayItemId en el lead (CREATE aún en cola): dependencia pendiente → PENDIENTE sin sumar intento', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ + leadId: 'loc_lead', + mondayItemId: null, // su CREAR_LEAD aún no se sincroniza + }) + const monday = mondayMock() + const store = storeEnMemoria([ + registro({ tipo: 'CONVERSION', payloadJson: JSON.stringify(payloadConversion), referenciaLocal: 'loc_lead' }), + ]) + const svc = crearServicio(monday, store, stateEnMemoria()) + const r = await svc.procesarPendientes() + expect(r).toEqual({ procesados: 0, fallos: 0 }) // no cuenta como fallo + const row = store.rows.get(store.ultimo.id) + expect(row?.estado).toBe('PENDIENTE') + expect(row?.intentos).toBe(0) // sin sumar intento + expect(row?.ultimoError).toContain('conversión') + expect(monday.oportunidadesCreadas).toEqual([]) // no tocó Monday + }) + + it('con el lead ya sincronizado: crea la oportunidad, marca el lead Calificado y actualiza ambos ids locales', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_lead', mondayItemId: 'monday-lead-9' }) + const monday = mondayMock() + const store = storeEnMemoria([ + registro({ tipo: 'CONVERSION', payloadJson: JSON.stringify(payloadConversion), referenciaLocal: 'loc_lead' }), + ]) + const svc = crearServicio(monday, store, stateEnMemoria()) + const r = await svc.procesarPendientes() + expect(r).toEqual({ procesados: 1, fallos: 0 }) + expect(monday.oportunidadesCreadas).toEqual([payloadConversion.datos]) + // board 444 = MONDAY_BOARD_LEADS de test + expect(monday.cambios).toEqual([ + { boardId: '444', itemId: 'monday-lead-9', columna: 'Estado', valor: 'Calificado' }, + ]) + // Oportunidad local actualizada con su id Monday + expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({ + where: { leadId: 'loc_op' }, + data: { mondayItemId: 'monday-op-1', pendienteSync: false }, + }) + // Mapping con ids Monday para el alta de clientes + expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ + data: { leadId: 'monday-lead-9', oportunidadId: 'monday-op-1' }, + }) + }) +}) + +describe('handler CREAR_CONTACTO', () => { + it('crea el Contacto y liga Cliente.mondayContactoId', async () => { + const monday = mondayMock() + const store = storeEnMemoria([ + registro({ + tipo: 'CREAR_CONTACTO', + payloadJson: JSON.stringify({ datos: { nombre: 'Cliente Demo', estadoLabel: 'CLIENTE', curp: 'CURP1' } }), + referenciaLocal: 'cli-1', + }), + ]) + const svc = crearServicio(monday, store, stateEnMemoria()) + const r = await svc.procesarPendientes() + expect(r).toEqual({ procesados: 1, fallos: 0 }) + expect(monday.contactosCreados).toHaveLength(1) + expect(prismaMock.cliente.updateMany).toHaveBeenCalledWith({ + where: { id: 'cli-1' }, + data: { mondayContactoId: 'monday-cont-1' }, + }) + }) +}) + +describe('handler COTIZACION_OPORTUNIDAD', () => { + const datosCot = { + clienteNombre: 'Cliente Demo', + unidadCodigo: 'U-101', + tipoPropuestaLabel: 'CREDITO BANCARIO', + precioLista: 1_000_000, + descuentoPct: 0, + precioFinal: 1_000_000, + fechaCotizacion: '2026-09-05', + estadoLabel: 'Cotizador', + } + + it('con oportunidadId Monday: ACTUALIZA esa oportunidad y liga Cotizacion.mondayItemId', async () => { + const monday = mondayMock() + const store = storeEnMemoria([ + registro({ + tipo: 'COTIZACION_OPORTUNIDAD', + payloadJson: JSON.stringify({ datos: datosCot, oportunidadId: 'op-77' }), + referenciaLocal: 'cot-1', + }), + ]) + const svc = crearServicio(monday, store, stateEnMemoria()) + const r = await svc.procesarPendientes() + expect(r).toEqual({ procesados: 1, fallos: 0 }) + expect(monday.oportunidadesCreadas).toEqual([]) // no duplica + expect(prismaMock.cotizacion.updateMany).toHaveBeenCalledWith({ + where: { id: 'cot-1' }, + data: { mondayItemId: 'op-77' }, + }) + }) + + it('con oportunidadId local (loc_*) sin sincronizar: dependencia pendiente', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op', mondayItemId: null }) + const monday = mondayMock() + const store = storeEnMemoria([ + registro({ + tipo: 'COTIZACION_OPORTUNIDAD', + payloadJson: JSON.stringify({ datos: datosCot, oportunidadId: 'loc_op' }), + referenciaLocal: 'cot-2', + }), + ]) + const svc = crearServicio(monday, store, stateEnMemoria()) + const r = await svc.procesarPendientes() + expect(r).toEqual({ procesados: 0, fallos: 0 }) + expect(store.rows.get(store.ultimo.id)?.estado).toBe('PENDIENTE') + expect(store.rows.get(store.ultimo.id)?.intentos).toBe(0) + }) + + it('sin oportunidad: match por teléfono como el controller; sin match crea una nueva', async () => { + funnelMock.oportunidadPorTelefono.mockResolvedValue(null) + const monday = mondayMock() + const store = storeEnMemoria([ + registro({ + tipo: 'COTIZACION_OPORTUNIDAD', + payloadJson: JSON.stringify({ datos: datosCot, clienteTelefono: '4771234567' }), + referenciaLocal: 'cot-3', + }), + ]) + const svc = crearServicio(monday, store, stateEnMemoria()) + await svc.procesarPendientes() + expect(funnelMock.oportunidadPorTelefono).toHaveBeenCalledWith('4771234567') + expect(monday.oportunidadesCreadas).toEqual([datosCot]) + expect(prismaMock.cotizacion.updateMany).toHaveBeenCalledWith({ + where: { id: 'cot-3' }, + data: { mondayItemId: 'monday-op-cot-1' }, + }) + }) +}) + +describe('handler CAMBIO_ETAPA', () => { + it('aplica el cambio al mondayItemId resuelto desde la referencia local', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'op-55' }) + const monday = mondayMock() + const store = storeEnMemoria([ + registro({ + tipo: 'CAMBIO_ETAPA', + payloadJson: JSON.stringify({ board: 'oportunidades', label: 'Cotizador' }), + referenciaLocal: 'loc_1', + }), + ]) + const svc = crearServicio(monday, store, stateEnMemoria()) + await svc.procesarPendientes() + // board 555 = MONDAY_BOARD_OPORTUNIDADES de test + expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-55', columna: 'Estado', valor: 'Cotizador' }]) + expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({ + where: { leadId: 'loc_1' }, + data: { pendienteSync: false }, + }) + }) +}) + +describe('handler APARTO_OPORTUNIDAD', () => { + it('mueve la oportunidad a Aparto con dedup previo (como moverAApartoConDedup)', async () => { + prismaMock.leadOportunidad.findUnique.mockResolvedValue({ leadId: 'lead-9', oportunidadId: 'op-9' }) + const monday = mondayMock() + const state = stateEnMemoria() + const store = storeEnMemoria([ + registro({ tipo: 'APARTO_OPORTUNIDAD', payloadJson: JSON.stringify({ leadId: 'lead-9' }), referenciaLocal: 'lead-9' }), + ]) + const svc = crearServicio(monday, store, state) + await svc.procesarPendientes() + // Dedup ANTES del cambio de etapa (prismaStateStore, como moverAApartoConDedup) + expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { clave: 'apartado-creado:op-9' } }), + ) + expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Aparto' }]) + }) +}) + +describe('handler LIQUIDADO_ESCRITURAR', () => { + it('mueve el apartado a X Escriturar (board 666 de test)', async () => { + const monday = mondayMock() + const store = storeEnMemoria([ + registro({ + tipo: 'LIQUIDADO_ESCRITURAR', + payloadJson: JSON.stringify({ mondayItemId: 'apt-55' }), + referenciaLocal: 'apt-55', + }), + ]) + const svc = crearServicio(monday, store, stateEnMemoria()) + await svc.procesarPendientes() + expect(monday.cambios).toEqual([{ boardId: '666', itemId: 'apt-55', columna: 'Estado', valor: 'X Escriturar' }]) + }) +}) + +describe('handler ASIGNAR_PERSONA', () => { + it('resuelve el mondayUserId del usuario local y asigna en el board correcto', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'lead-55' }) + prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 777 }) + const monday = mondayMock() + const store = storeEnMemoria([ + registro({ + tipo: 'ASIGNAR_PERSONA', + payloadJson: JSON.stringify({ board: 'leads', usuarioId: 'u-1' }), + referenciaLocal: 'loc_1', + }), + ]) + const svc = crearServicio(monday, store, stateEnMemoria()) + const r = await svc.procesarPendientes() + expect(r).toEqual({ procesados: 1, fallos: 0 }) + expect(monday.asignaciones).toEqual([{ itemId: 'lead-55', userId: 777 }]) + }) + + it('sin mapeo Monday del usuario: no-op exitoso (reintentar no crearía el mapeo)', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'lead-55' }) + prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: null }) + const monday = mondayMock() + const store = storeEnMemoria([ + registro({ + tipo: 'ASIGNAR_PERSONA', + payloadJson: JSON.stringify({ board: 'leads', usuarioId: 'u-1' }), + referenciaLocal: 'loc_1', + }), + ]) + const svc = crearServicio(monday, store, stateEnMemoria()) + const r = await svc.procesarPendientes() + expect(r).toEqual({ procesados: 1, fallos: 0 }) + expect(monday.asignaciones).toEqual([]) + expect(store.rows.get(store.ultimo.id)?.estado).toBe('HECHO') + }) +}) diff --git a/api-gateway/src/services/sync-handlers.ts b/api-gateway/src/services/sync-handlers.ts new file mode 100644 index 0000000..87381d4 --- /dev/null +++ b/api-gateway/src/services/sync-handlers.ts @@ -0,0 +1,289 @@ +import { config } from '../config/index.js' +import { claveApartadoCreado, prismaStateStore } from './automatizaciones.service.js' +import { esIdLocal } from './crm-local.service.js' +import { funnelService } from './funnel.service.js' +import type { + DatosApartado, + DatosContacto, + DatosLead, + DatosOportunidad, + DatosOportunidadAfluencia, +} from './monday.service.js' +import { prisma } from './prisma.js' +import { + DependenciaPendienteError, + syncMondayService, + type SyncMondayService, +} from './sync-monday.service.js' + +/** + * Handlers del sync worker (modo local): una réplica Monday por cada tipo de + * SyncPendiente que encolan crm-local.service y los controllers. + * + * Convenciones: + * - `ctx.referenciaLocal` es el id local de la entidad (leadId loc_* / + * clienteId / cotizacionId); los payloads traen TODO lo necesario para la + * llamada Monday (los mismos datos que mandan hoy los controllers). + * - Cuando la réplica necesita el mondayItemId de algo que aún no se ha creado + * (su CREATE sigue en la cola), se lanza DependenciaPendienteError: el worker + * lo deja PENDIENTE sin sumar intento y lo reintenta en la próxima pasada. + * - Tras el éxito se actualizan los ids locales (LeadCaptura.mondayItemId + + * pendienteSync=false, Cliente.mondayContactoId/mondayApartadoId, + * Cotizacion.mondayItemId). + * + * Wiring: index.ts importa este módulo por su efecto lateral (registro sobre + * el singleton). Los tests llaman `registrarHandlersSync(svcPropio)`. + */ + +/** + * Resuelve el id Monday de un item del embudo: + * - id Monday directo (o fila local ya sincronizada): se devuelve tal cual. + * - id local loc_*: el mondayItemId de su fila LeadCaptura; null si su CREATE + * aún no se sincroniza (→ DependenciaPendienteError en el caller). + */ +async function resolverMondayId(id: string | null | undefined): Promise { + if (id === null || id === undefined) return null + const row = await prisma.leadCaptura.findUnique({ where: { leadId: id } }) + if (row) return row.mondayItemId ?? (esIdLocal(row.leadId) ? null : row.leadId) + // Sin fila local: un id Monday viejo se usa tal cual; un loc_* huérfano no + // tiene destino (null → dependencia pendiente) + return esIdLocal(id) ? null : id +} + +/** Lanza DependenciaPendienteError si el id Monday aún no se puede resolver */ +function requerirMondayId(id: string | null, que: string): string { + if (id === null) { + throw new DependenciaPendienteError(`${que}: el item aún no existe en Monday (su CREATE sigue en la cola)`) + } + return id +} + +/** Marca la fila local del embudo como sincronizada (updateMany: no truena si no existe) */ +async function marcarCapturaSincronizada(leadId: string, mondayItemId?: string): Promise { + await prisma.leadCaptura.updateMany({ + where: { leadId }, + data: { ...(mondayItemId !== undefined ? { mondayItemId } : {}), pendienteSync: false }, + }) +} + +/** Registra todos los handlers sobre el servicio dado (singleton en prod) */ +export function registrarHandlersSync(svc: SyncMondayService = syncMondayService): void { + // ---------- Captura (tableros Leads y Oportunidades) ---------- + + svc.registrarHandler('CREAR_LEAD', async (payload, ctx) => { + const id = await ctx.monday.crearLead(payload['datos'] as DatosLead) + if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal, id) + return id + }) + + svc.registrarHandler('CREAR_AFLUENCIA', async (payload, ctx) => { + const id = await ctx.monday.crearOportunidadAfluencia(payload['datos'] as DatosOportunidadAfluencia) + if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal, id) + return id + }) + + svc.registrarHandler('ACTUALIZAR_AFLUENCIA', async (payload, ctx) => { + const id = requerirMondayId( + await resolverMondayId(ctx.referenciaLocal ?? (payload['oportunidadId'] as string | undefined)), + 'actualizar afluencia', + ) + await ctx.monday.actualizarOportunidadAfluencia(id, payload['datos'] as DatosOportunidadAfluencia) + if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal) + return id + }) + + svc.registrarHandler('CAMBIO_ETAPA', async (payload, ctx) => { + const board = payload['board'] as 'leads' | 'oportunidades' | 'apartado' + const label = payload['label'] as string + const fecha = payload['fecha'] as string | undefined + const id = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), `cambio de etapa a "${label}"`) + const boardId = config.monday.boards[board] + await ctx.monday.cambiarValorColumna(boardId, id, 'Estado', label) + if (fecha !== undefined) await ctx.monday.cambiarValorColumna(boardId, id, 'Fecha', fecha) + if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal) + return id + }) + + svc.registrarHandler('DESCARTE', async (payload, ctx) => { + const fuente = (payload['fuente'] as 'lead' | 'oportunidad' | undefined) ?? 'oportunidad' + const motivo = payload['motivo'] as string + const subcategoria = payload['subcategoria'] as string | undefined + const comentario = payload['comentario'] as string | undefined + const nombreItem = (payload['nombreItem'] as string | undefined) ?? '' + const id = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'descarte') + if (fuente === 'lead') { + const boardLeads = config.monday.boards.leads + await ctx.monday.cambiarValorColumna(boardLeads, id, 'Estado', 'Descartado') + if (comentario !== undefined) await ctx.monday.cambiarValorColumna(boardLeads, id, 'Notas', comentario) + } else { + const boardOportunidades = config.monday.boards.oportunidades + await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'Estado', 'Perdido') + if (comentario !== undefined) await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'COMENTARIOS', comentario) + await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'Motivo No Compra', motivo) + } + const motivoItemId = await ctx.monday.crearMotivoNoCompra({ + nombre: `${motivo}${subcategoria !== undefined ? ` / ${subcategoria}` : ''} — ${nombreItem}`, + categoria: motivo, + ...(subcategoria !== undefined ? { subcategoria } : {}), + ...(comentario !== undefined ? { descripcion: comentario } : {}), + }) + if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal) + return motivoItemId + }) + + svc.registrarHandler('CONVERSION', async (payload, ctx) => { + const oportunidadIdLocal = payload['oportunidadIdLocal'] as string + const datos = payload['datos'] as DatosOportunidadAfluencia + const leadMondayId = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'conversión (lead)') + const oportunidadId = await ctx.monday.crearOportunidadAfluencia(datos) + // El lead queda Calificado (convertido: sale de Mis leads) + await ctx.monday.cambiarValorColumna(config.monday.boards.leads, leadMondayId, 'Estado', 'Calificado') + if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal) + await marcarCapturaSincronizada(oportunidadIdLocal, oportunidadId) + // Mapping con los ids de Monday para el alta de clientes (moverAApartoConDedup) + try { + await prisma.leadOportunidad.create({ data: { leadId: leadMondayId, oportunidadId } }) + } catch { + // ya existe (reintento tras pasada a medias): el dedup sync: lo cubre + } + return oportunidadId + }) + + // ---------- Alta de clientes (tableros Contactos y Apartado) ---------- + + svc.registrarHandler('CREAR_CONTACTO', async (payload, ctx) => { + const id = await ctx.monday.crearContacto(payload['datos'] as DatosContacto) + if (ctx.referenciaLocal !== null) { + await prisma.cliente.updateMany({ where: { id: ctx.referenciaLocal }, data: { mondayContactoId: id } }) + } + return id + }) + + svc.registrarHandler('ACTUALIZAR_CONTACTO', async (payload, ctx) => { + const cliente = ctx.referenciaLocal !== null + ? await prisma.cliente.findUnique({ where: { id: ctx.referenciaLocal } }) + : null + const id = cliente?.mondayContactoId ?? null + if (id === null) { + throw new DependenciaPendienteError('actualizar contacto: el Contacto aún no existe en Monday') + } + await ctx.monday.actualizarContacto(id, payload['datos'] as Partial) + return id + }) + + svc.registrarHandler('CREAR_APARTADO', async (payload, ctx) => { + const id = await ctx.monday.crearApartado(payload['datos'] as DatosApartado) + // Alta de clientes: la referencia es el cliente local + if (typeof payload['clienteId'] === 'string') { + await prisma.cliente.updateMany({ where: { id: payload['clienteId'] }, data: { mondayApartadoId: id } }) + } + if (ctx.referenciaLocal !== null) { + // "Aparto" local: la referencia es la fila LeadCaptura board=APARTADO + await marcarCapturaSincronizada(ctx.referenciaLocal, id) + // Clientes mínimos creados durante la caída (subida de documentos) ligados + // al id local del apartado: apuntan ahora al item real de Monday + await prisma.cliente.updateMany({ + where: { mondayApartadoId: ctx.referenciaLocal }, + data: { mondayApartadoId: id }, + }) + } + return id + }) + + // ---------- Cotizador ---------- + + svc.registrarHandler('COTIZACION_OPORTUNIDAD', async (payload, ctx) => { + const datos = payload['datos'] as DatosOportunidad + const oportunidadIdPayload = payload['oportunidadId'] as string | undefined + const clienteTelefono = payload['clienteTelefono'] as string | undefined + let oportunidadId: string | null = null + if (oportunidadIdPayload !== undefined) { + oportunidadId = requerirMondayId(await resolverMondayId(oportunidadIdPayload), 'cotización (oportunidad)') + } else if (clienteTelefono !== undefined) { + // Monday ya está vivo en la pasada: mismo match por teléfono que el controller + oportunidadId = await funnelService.oportunidadPorTelefono(clienteTelefono) + } + if (oportunidadId !== null) { + await ctx.monday.actualizarOportunidadCotizacion(oportunidadId, datos) + } else { + oportunidadId = await ctx.monday.crearOportunidad(datos) + } + if (ctx.referenciaLocal !== null) { + await prisma.cotizacion.updateMany({ where: { id: ctx.referenciaLocal }, data: { mondayItemId: oportunidadId } }) + } + return oportunidadId + }) + + // ---------- Documentos y pipeline de apartados ---------- + + svc.registrarHandler('CHECKBOX_DOC', async (payload, ctx) => { + const checkbox = payload['checkbox'] as string + // El item Monday del apartado viaja en el payload (puede ser loc_* si el + // apartado nació en modo local: se resuelve por su fila LeadCaptura) + const itemId = (payload['itemId'] as string | undefined) ?? ctx.referenciaLocal + const id = requerirMondayId(await resolverMondayId(itemId), `checkbox "${checkbox}"`) + await ctx.monday.cambiarValorColumna(config.monday.boards.apartado, id, checkbox, true) + return id + }) + + svc.registrarHandler('APARTO_OPORTUNIDAD', async (payload, ctx) => { + const leadId = payload['leadId'] as string + // Misma resolución que funnel.moverAApartoConDedup: primero como + // oportunidad directa (modelo nuevo), luego como lead con mapping; los ids + // pueden ser locales loc_* (alta hecha en modo local) + let oportunidadId: string | null = null + const porOportunidad = await prisma.leadOportunidad.findUnique({ where: { oportunidadId: leadId } }) + if (porOportunidad) { + oportunidadId = porOportunidad.oportunidadId + } else { + const porLead = await prisma.leadOportunidad.findUnique({ where: { leadId } }) + oportunidadId = porLead?.oportunidadId ?? null + } + if (oportunidadId === null) { + const captura = await prisma.leadCaptura.findUnique({ where: { leadId } }) + if (captura?.board === 'OPORTUNIDADES') oportunidadId = captura.leadId + } + if (oportunidadId === null) throw new Error(`APARTO_OPORTUNIDAD sin mapping para ${leadId}`) + const opMonday = requerirMondayId(await resolverMondayId(oportunidadId), 'mover a Aparto') + // Dedup ANTES del cambio de etapa (el webhook/cron no debe crear el Apartado) + await prismaStateStore.set(claveApartadoCreado(opMonday), new Date().toISOString()) + await ctx.monday.cambiarValorColumna(config.monday.boards.oportunidades, opMonday, 'Estado', 'Aparto') + return opMonday + }) + + svc.registrarHandler('LIQUIDADO_ESCRITURAR', async (payload, ctx) => { + const id = requerirMondayId(await resolverMondayId(payload['mondayItemId'] as string), 'liquidado → X Escriturar') + await ctx.monday.cambiarValorColumna(config.monday.boards.apartado, id, 'Estado', 'X Escriturar') + return id + }) + + // Réplica del status de una unidad cambiado en modo local (espejo → Monday) + svc.registrarHandler('DISPONIBILIDAD_UNIDAD', async (payload, ctx) => { + const codigo = payload['codigo'] as string + const status = payload['status'] as 'DISPONIBLE' | 'APARTADA' | 'VENDIDA' + await ctx.monday.actualizarDisponibilidadUnidad(codigo, status) + return codigo + }) + + svc.registrarHandler('ASIGNAR_PERSONA', async (payload, ctx) => { + const board = payload['board'] as 'leads' | 'oportunidades' + const itemId = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'asignación de persona') + let userId = (payload['mondayUserId'] as number | undefined) ?? null + if (userId === null && typeof payload['usuarioId'] === 'string') { + const usuario = await prisma.usuario.findUnique({ + where: { id: payload['usuarioId'] }, + select: { mondayUserId: true }, + }) + userId = usuario?.mondayUserId ?? null + } + // Sin cuenta Monday vinculada no hay a quién asignar: no es un error + // (reintentar no crearía el mapeo), queda como no-op exitoso + if (userId === null) return 'sin-mapeo' + if (board === 'leads') await ctx.monday.asignarVendedorALead(itemId, userId) + else await ctx.monday.asignarAsesorAOportunidad(itemId, userId) + return itemId + }) +} + +// Wiring en prod: registrar sobre el singleton al importar el módulo (index.ts) +registrarHandlersSync() diff --git a/api-gateway/src/services/sync-monday.service.test.ts b/api-gateway/src/services/sync-monday.service.test.ts new file mode 100644 index 0000000..20b17af --- /dev/null +++ b/api-gateway/src/services/sync-monday.service.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest' +import { AppError } from '../utils/errors.js' +import type { StateStore } from './automatizaciones.service.js' +import type { MondayService } from './monday.service.js' +import { + claveSync, + esFalloMonday, + MAX_INTENTOS_SYNC, + SyncMondayService, + type SyncRegistro, + type SyncStore, +} from './sync-monday.service.js' + +// ---------- Fixtures ---------- + +let consecutivo = 0 +function registro(parcial: Partial = {}): SyncRegistro { + consecutivo += 1 + return { + id: parcial.id ?? `reg-${consecutivo}`, + tipo: 'CREAR_LEAD', + payloadJson: JSON.stringify({ nombre: 'Lead de prueba' }), + referenciaLocal: null, + estado: 'PENDIENTE', + intentos: 0, + // createdAt creciente: el orden del arreglo ES el orden FIFO + createdAt: new Date(Date.UTC(2026, 8, 5, 12, 0, consecutivo)), + ...parcial, + } +} + +/** StateStore en memoria (mismo patrón que automatizaciones.service.test.ts) */ +function stateEnMemoria(): StateStore & { mapa: Map } { + const mapa = new Map() + return { + mapa, + async get(clave) { + return mapa.get(clave) ?? null + }, + async set(clave, valor) { + mapa.set(clave, valor) + }, + } +} + +/** SyncStore en memoria: guarda los registros y el historial de marcados */ +function storeEnMemoria(registros: SyncRegistro[]) { + const rows = new Map(registros.map((r) => [r.id, { ...r }])) + const marcados: { id: string; datos: { estado: string; intentos?: number; ultimoError?: string | null } }[] = [] + const store: SyncStore & { rows: Map; marcados: typeof marcados } = { + rows, + marcados, + async pendientes(limite) { + return [...rows.values()] + .filter((r) => r.estado === 'PENDIENTE') + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .slice(0, limite) + }, + async marcar(id, datos) { + marcados.push({ id, datos }) + const row = rows.get(id) + if (!row) throw new Error(`Registro inexistente: ${id}`) + row.estado = datos.estado + if (datos.intentos !== undefined) row.intentos = datos.intentos + if (datos.ultimoError !== undefined) row.ultimoError = datos.ultimoError + }, + } + return store +} + +const saludSana = { estaDisponible: () => true } +const saludCaida = { estaDisponible: () => false } + +// En fase A no hay handlers por defecto; monday solo se pasa al contexto +const mondayDummy = {} as MondayService + +function crearServicio(store: SyncStore, state: StateStore, salud = saludSana) { + return new SyncMondayService(mondayDummy, state, store, salud) +} + +// ---------- Tests ---------- + +describe('esFalloMonday', () => { + it('solo los AppError 502 cuentan como fallo de Monday', () => { + expect(esFalloMonday(new AppError(502, 'Monday no respondió'))).toBe(true) + expect(esFalloMonday(new AppError(500, 'otro'))).toBe(false) + expect(esFalloMonday(new Error('x'))).toBe(false) + }) +}) + +describe('SyncMondayService.procesarPendientes', () => { + it('no-op cuando Monday no está disponible (no toca la cola)', async () => { + const store = storeEnMemoria([registro()]) + const state = stateEnMemoria() + const svc = crearServicio(store, state, saludCaida) + const r = await svc.procesarPendientes() + expect(r).toEqual({ procesados: 0, fallos: 0 }) + expect(store.marcados).toHaveLength(0) + expect(store.rows.get('reg-1')?.estado).toBe('PENDIENTE') + }) + + it('procesa FIFO y marca HECHO tras ejecutar el handler', async () => { + const store = storeEnMemoria([ + registro({ id: 'a', createdAt: new Date('2026-09-05T12:00:02Z') }), + registro({ id: 'b', createdAt: new Date('2026-09-05T12:00:01Z') }), + ]) + const state = stateEnMemoria() + const svc = crearServicio(store, state) + const orden: string[] = [] + svc.registrarHandler('CREAR_LEAD', async (payload) => { + orden.push(String(payload.orden)) + return 'item-1' + }) + // Reescribo payloads para distinguir orden de ejecución + store.rows.get('a')!.payloadJson = JSON.stringify({ orden: 'segundo-en-fila' }) + store.rows.get('b')!.payloadJson = JSON.stringify({ orden: 'primero-en-fila' }) + + const r = await svc.procesarPendientes() + expect(r).toEqual({ procesados: 2, fallos: 0 }) + expect(orden).toEqual(['primero-en-fila', 'segundo-en-fila']) // b (más viejo) primero + expect(store.rows.get('a')?.estado).toBe('HECHO') + expect(store.rows.get('b')?.estado).toBe('HECHO') + // Dedup persistido con el id devuelto por el handler + expect(state.mapa.get(claveSync('a'))).toBe('item-1') + expect(state.mapa.get(claveSync('b'))).toBe('item-1') + }) + + it('marca ERROR con mensaje claro cuando el tipo no tiene handler', async () => { + const store = storeEnMemoria([registro({ id: 'x', tipo: 'DESCARTE' })]) + const svc = crearServicio(store, stateEnMemoria()) + const r = await svc.procesarPendientes() + expect(r).toEqual({ procesados: 0, fallos: 1 }) + const row = store.rows.get('x') + expect(row?.estado).toBe('ERROR') + expect(row?.ultimoError).toMatch(/sin handler registrado: "DESCARTE"/) + }) + + it('marca ERROR cuando el payloadJson no es JSON válido', async () => { + const store = storeEnMemoria([registro({ id: 'x', payloadJson: '{roto' })]) + const svc = crearServicio(store, stateEnMemoria()) + svc.registrarHandler('CREAR_LEAD', async () => {}) + const r = await svc.procesarPendientes() + expect(r.fallos).toBe(1) + expect(store.rows.get('x')?.estado).toBe('ERROR') + expect(store.rows.get('x')?.ultimoError).toMatch(/payloadJson inválido/) + }) + + it('fallo Monday (502): vuelve a PENDIENTE con intentos+1 (backoff)', async () => { + const store = storeEnMemoria([registro({ id: 'x', intentos: 2 })]) + const svc = crearServicio(store, stateEnMemoria()) + svc.registrarHandler('CREAR_LEAD', async () => { + throw new AppError(502, 'Monday no respondió tras 4 intentos') + }) + const r = await svc.procesarPendientes() + expect(r).toEqual({ procesados: 0, fallos: 1 }) + const row = store.rows.get('x') + expect(row?.estado).toBe('PENDIENTE') + expect(row?.intentos).toBe(3) + expect(row?.ultimoError).toMatch(/Monday no respondió/) + }) + + it('al superar MAX_INTENTOS_SYNC el fallo Monday queda ERROR definitivo', async () => { + const store = storeEnMemoria([registro({ id: 'x', intentos: MAX_INTENTOS_SYNC })]) + const svc = crearServicio(store, stateEnMemoria()) + svc.registrarHandler('CREAR_LEAD', async () => { + throw new AppError(502, 'Monday no respondió') + }) + await svc.procesarPendientes() + const row = store.rows.get('x') + expect(row?.estado).toBe('ERROR') + expect(row?.intentos).toBe(MAX_INTENTOS_SYNC + 1) + }) + + it('un error que NO es de Monday marca ERROR sin reintento', async () => { + const store = storeEnMemoria([registro({ id: 'x' })]) + const svc = crearServicio(store, stateEnMemoria()) + svc.registrarHandler('CREAR_LEAD', async () => { + throw new Error('payload incompleto: falta nombre') + }) + await svc.procesarPendientes() + expect(store.rows.get('x')?.estado).toBe('ERROR') + expect(store.rows.get('x')?.ultimoError).toMatch(/payload incompleto/) + }) + + it('idempotencia: con sync: ya en el state no reejecuta el handler', async () => { + const store = storeEnMemoria([registro({ id: 'x' })]) + const state = stateEnMemoria() + state.mapa.set(claveSync('x'), 'item-ya-creado') + const svc = crearServicio(store, state) + let llamadas = 0 + svc.registrarHandler('CREAR_LEAD', async () => { + llamadas++ + }) + const r = await svc.procesarPendientes() + expect(r).toEqual({ procesados: 1, fallos: 0 }) + expect(llamadas).toBe(0) // no duplica el item en Monday + expect(store.rows.get('x')?.estado).toBe('HECHO') + }) + + it('respeta el límite por pasada', async () => { + const store = storeEnMemoria([registro({ id: 'a' }), registro({ id: 'b' }), registro({ id: 'c' })]) + const svc = crearServicio(store, stateEnMemoria()) + svc.registrarHandler('CREAR_LEAD', async () => {}) + const r = await svc.procesarPendientes({ limite: 2 }) + expect(r.procesados).toBe(2) + expect(store.rows.get('c')?.estado).toBe('PENDIENTE') + }) + + it('una pasada en curso bloquea la siguiente (mutex)', async () => { + const store = storeEnMemoria([registro({ id: 'x' })]) + const svc = crearServicio(store, stateEnMemoria()) + let liberar!: () => void + const bloqueo = new Promise((res) => { + liberar = res + }) + svc.registrarHandler('CREAR_LEAD', async () => { + await bloqueo + }) + const pasada1 = svc.procesarPendientes() + // Segunda pasada mientras la primera sigue: no-op inmediato + await expect(svc.procesarPendientes()).resolves.toEqual({ procesados: 0, fallos: 0 }) + liberar() + await expect(pasada1).resolves.toEqual({ procesados: 1, fallos: 0 }) + }) +}) diff --git a/api-gateway/src/services/sync-monday.service.ts b/api-gateway/src/services/sync-monday.service.ts new file mode 100644 index 0000000..8707abe --- /dev/null +++ b/api-gateway/src/services/sync-monday.service.ts @@ -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:` (mismo patrón que + * `apartado-creado:` 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 + marcar( + id: string, + datos: { estado: string; intentos?: number; ultimoError?: string | null }, + ): Promise +} + +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:`; void se persiste como 'ok'. + */ +export type HandlerSync = ( + payload: Record, + ctx: ContextoSync, +) => Promise + +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() + /** 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 = 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 { + 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:`; lanza para que el caller clasifique */ + private async ejecutar(registro: SyncRegistro): Promise { + const handler = this.handlers.get(registro.tipo) + if (!handler) throw new Error(`Tipo de sync sin handler registrado: "${registro.tipo}"`) + let payload: Record + try { + payload = JSON.parse(registro.payloadJson) as Record + } 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) diff --git a/api-gateway/src/services/unidades-local.service.test.ts b/api-gateway/src/services/unidades-local.service.test.ts new file mode 100644 index 0000000..bfea782 --- /dev/null +++ b/api-gateway/src/services/unidades-local.service.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AppError } from '../utils/errors.js' + +// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ---------- + +const prismaMock = vi.hoisted(() => ({ + unidad: { + findMany: vi.fn(), + findUnique: vi.fn(), + }, +})) + +const mondayMock = vi.hoisted(() => ({ + getUnidades: vi.fn(), + getUnidad: vi.fn(), +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) +vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) + +import { + getUnidadesLocal, + getUnidadLocal, + listarUnidadesConFallback, + obtenerUnidadConFallback, + unidadLocalADto, + type FilaUnidad, +} from './unidades-local.service.js' +import { saludMonday } from './salud-monday.service.js' + +function fila(parcial: Partial & { codigo: string }): FilaUnidad { + return { + desarrollo: 'SENDA', + modelo: '1R', + supConstruccion: 69.5, + supExcedente: 0, + supEstacionamiento: 12.5, + precioLista: 4_415_000, + precioAcabados: 4_579_000, + status: 'DISPONIBLE', + etapa: 'Entrega inmediata', + ...parcial, + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible') + saludMonday.restablecer() + prismaMock.unidad.findMany.mockResolvedValue([]) + prismaMock.unidad.findUnique.mockResolvedValue(null) +}) + +describe('unidadLocalADto', () => { + it('mapea la fila local al DTO Unidad (misma forma que Monday)', () => { + const dto = unidadLocalADto(fila({ codigo: 'SENDA-101' })) + expect(dto).toEqual({ + codigo: 'SENDA-101', + modelo: '1R', + fechaEntrega: null, + supEstacionamiento: 12.5, + supConstruccion: 69.5, + supExcedente: 0, + precioSinAcabados: 4_415_000, + precioConAcabados: 4_579_000, + status: 'DISPONIBLE', + desarrollo: 'SENDA', + mesesParaFirma: null, + etapa: 'Entrega inmediata', + }) + }) + + it('etapa vacía → null (como el mapeo Monday)', () => { + expect(unidadLocalADto(fila({ codigo: 'U-1', etapa: '' })).etapa).toBeNull() + }) +}) + +describe('getUnidadesLocal / getUnidadLocal', () => { + it('devuelve las filas mapeadas; soloDisponibles y desarrollo filtran', async () => { + prismaMock.unidad.findMany.mockResolvedValue([ + fila({ codigo: 'U-1', desarrollo: 'DIAGONAL' }), + fila({ codigo: 'U-2', desarrollo: 'SENDA', status: 'APARTADO' }), + fila({ codigo: 'U-3', desarrollo: 'senda' }), + ]) + const todas = await getUnidadesLocal() + expect(todas.map((u) => u.codigo)).toEqual(['U-1', 'U-2', 'U-3']) + + const disponibles = await getUnidadesLocal({ soloDisponibles: true }) + expect(disponibles.map((u) => u.codigo)).toEqual(['U-1', 'U-3']) + + const senda = await getUnidadesLocal({ desarrollo: 'SENDA' }) + expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3']) + }) + + it('getUnidadLocal: null si no existe', async () => { + prismaMock.unidad.findUnique.mockImplementation(async ({ where }) => + where.codigo === 'U-9' ? fila({ codigo: 'U-9' }) : null, + ) + expect((await getUnidadLocal('U-9'))?.codigo).toBe('U-9') + expect(await getUnidadLocal('U-X')).toBeNull() + }) +}) + +describe('fallbacks con circuit breaker', () => { + it('Monday caído (salud) → lee local sin llamar a Monday', async () => { + vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + prismaMock.unidad.findMany.mockResolvedValue([fila({ codigo: 'U-1' })]) + prismaMock.unidad.findUnique.mockResolvedValue(fila({ codigo: 'U-1' })) + + const lista = await listarUnidadesConFallback() + expect(lista.map((u) => u.codigo)).toEqual(['U-1']) + expect(mondayMock.getUnidades).not.toHaveBeenCalled() + + const unidad = await obtenerUnidadConFallback('U-1') + expect(unidad?.codigo).toBe('U-1') + expect(mondayMock.getUnidad).not.toHaveBeenCalled() + }) + + it('Monday vivo → camino normal (sin tocar local)', async () => { + mondayMock.getUnidades.mockResolvedValue([{ codigo: 'M-1' }]) + mondayMock.getUnidad.mockResolvedValue({ codigo: 'M-1' }) + expect(await listarUnidadesConFallback()).toEqual([{ codigo: 'M-1' }]) + expect(await obtenerUnidadConFallback('M-1')).toEqual({ codigo: 'M-1' }) + expect(prismaMock.unidad.findMany).not.toHaveBeenCalled() + }) + + it('Monday lanza AppError 502 → fallback local', async () => { + mondayMock.getUnidades.mockRejectedValue(new AppError(502, 'Monday no respondió')) + mondayMock.getUnidad.mockRejectedValue(new AppError(502, 'Monday no respondió')) + prismaMock.unidad.findMany.mockResolvedValue([fila({ codigo: 'U-1' })]) + prismaMock.unidad.findUnique.mockResolvedValue(fila({ codigo: 'U-1' })) + + expect((await listarUnidadesConFallback()).map((u) => u.codigo)).toEqual(['U-1']) + expect((await obtenerUnidadConFallback('U-1'))?.codigo).toBe('U-1') + }) + + it('otro error de Monday se propaga (no se traga nada)', async () => { + mondayMock.getUnidades.mockRejectedValue(new AppError(500, 'bug')) + await expect(listarUnidadesConFallback()).rejects.toThrow('bug') + }) +}) diff --git a/api-gateway/src/services/unidades-local.service.ts b/api-gateway/src/services/unidades-local.service.ts new file mode 100644 index 0000000..d319b12 --- /dev/null +++ b/api-gateway/src/services/unidades-local.service.ts @@ -0,0 +1,100 @@ +import { mondayService, type Unidad } from './monday.service.js' +import { prisma } from './prisma.js' +import { saludMonday } from './salud-monday.service.js' +import { AppError } from '../utils/errors.js' + +/** + * Fallback de LECTURA del inventario (modo local): la tabla `Unidad` es el + * espejo local del tablero Unidades de Monday (la siembra + * scripts/seed-unidades-locales.js y la mantiene fresco el write-through de + * monday.service.getUnidades). Cuando Monday está caído (saludMonday) o lanza + * AppError 502, el cotizador lee de aquí con la MISMA forma de DTO. + * + * La tabla local no guarda fechaEntrega ni mesesParaFirma (no están en el + * seed curado): salen en null, que el cotizador ya tolera (plazo desconocido). + */ + +/** Fila de la tabla Unidad tal como la devuelve Prisma (estructural) */ +export interface FilaUnidad { + codigo: string + desarrollo: string + modelo: string + supConstruccion: number + supExcedente: number + supEstacionamiento: number + precioLista: number + precioAcabados: number + status: string + etapa: string +} + +/** Fila local → DTO `Unidad` (UnidadCotizable) que consumen controllers y fronts */ +export function unidadLocalADto(u: FilaUnidad): Unidad { + return { + codigo: u.codigo, + modelo: u.modelo, + fechaEntrega: null, // no está en el espejo local: plazo desconocido + supEstacionamiento: u.supEstacionamiento, + supConstruccion: u.supConstruccion, + supExcedente: u.supExcedente, + precioSinAcabados: u.precioLista, + precioConAcabados: u.precioAcabados, + status: u.status, + desarrollo: u.desarrollo, + mesesParaFirma: null, + etapa: u.etapa === '' ? null : u.etapa, + } +} + +/** true si el error es un fallo de Monday (AppError 502) que amerita fallback local */ +function esFalloMonday502(err: unknown): boolean { + return err instanceof AppError && err.status === 502 +} + +/** Unidades del espejo local; filtros equivalentes a los del controller */ +export async function getUnidadesLocal( + filtro: { desarrollo?: string | undefined; soloDisponibles?: boolean | undefined } = {}, +): Promise { + const rows = await prisma.unidad.findMany({ orderBy: { codigo: 'asc' } }) + let lista = rows.map(unidadLocalADto) + if (filtro.soloDisponibles === true) { + lista = lista.filter((u) => u.status.trim().toUpperCase() === 'DISPONIBLE') + } + if (filtro.desarrollo !== undefined && filtro.desarrollo !== '') { + const desarrollo = filtro.desarrollo.toUpperCase() + lista = lista.filter((u) => (u.desarrollo || 'DIAGONAL').toUpperCase() === desarrollo) + } + return lista +} + +/** Una unidad local por código; null si no existe en el espejo */ +export async function getUnidadLocal(codigo: string): Promise { + const row = await prisma.unidad.findUnique({ where: { codigo } }) + return row === null ? null : unidadLocalADto(row) +} + +/** + * Lectura con fallback: Monday si está disponible; espejo local si el circuit + * breaker lo da por caído o si la llamada lanza AppError 502. Cualquier otro + * error se propaga intacto (no se traga nada del camino feliz). + */ +export async function listarUnidadesConFallback(): Promise { + if (!saludMonday.estaDisponible()) return getUnidadesLocal() + try { + return await mondayService.getUnidades() + } catch (err) { + if (esFalloMonday502(err)) return getUnidadesLocal() + throw err + } +} + +/** Igual que listarUnidadesConFallback pero para una unidad por código */ +export async function obtenerUnidadConFallback(codigo: string): Promise { + if (!saludMonday.estaDisponible()) return getUnidadLocal(codigo) + try { + return await mondayService.getUnidad(codigo) + } catch (err) { + if (esFalloMonday502(err)) return getUnidadLocal(codigo) + throw err + } +} diff --git a/apps/alta-clientes/src/components/BannerModoLocal.tsx b/apps/alta-clientes/src/components/BannerModoLocal.tsx new file mode 100644 index 0000000..fefd817 --- /dev/null +++ b/apps/alta-clientes/src/components/BannerModoLocal.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react' +import { fetchEstadoSistema } from '../services/sistema' + +const INTERVALO_POLL_MS = 60_000 + +export function useModoLocal(): boolean { + const [modoLocal, setModoLocal] = useState(false) + useEffect(() => { + let activo = true + const consultar = () => { + void fetchEstadoSistema().then((estado) => { + if (activo) setModoLocal(estado !== null && !estado.mondayOk) + }) + } + consultar() + const id = setInterval(consultar, INTERVALO_POLL_MS) + return () => { activo = false; clearInterval(id) } + }, []) + return modoLocal +} + +export default function BannerModoLocal() { + const modoLocal = useModoLocal() + if (!modoLocal) return null + return ( +
+

+ Modo local — Monday no disponible. Tus capturas se guardan y se sincronizarán automáticamente. +

+
+ ) +} diff --git a/apps/alta-clientes/src/pages/AltaClientePage.tsx b/apps/alta-clientes/src/pages/AltaClientePage.tsx index bd87177..fdb7efa 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' import AppHeader from '../components/AppHeader' +import BannerModoLocal from '../components/BannerModoLocal' import Stepper from '../components/Stepper' import DatosGeneralesForm from '../forms/DatosGeneralesForm' import EmpleoForm from '../forms/EmpleoForm' @@ -333,6 +334,7 @@ export default function AltaClientePage() { return (
+

diff --git a/apps/alta-clientes/src/services/sistema.ts b/apps/alta-clientes/src/services/sistema.ts new file mode 100644 index 0000000..2515cf4 --- /dev/null +++ b/apps/alta-clientes/src/services/sistema.ts @@ -0,0 +1,19 @@ +import { conAuth, getToken } from './auth' + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api' + +export interface EstadoSistema { + mondayOk: boolean + pendientesSync: number +} + +export async function fetchEstadoSistema(): Promise { + if (!getToken()) return null + try { + const res = await fetch(`${API_URL}/sistema/estado`, conAuth()) + if (!res.ok) return null + return (await res.json()) as EstadoSistema + } catch { + return null + } +} diff --git a/apps/cotizador/package-lock.json b/apps/cotizador/package-lock.json index b76cf99..690a667 100644 --- a/apps/cotizador/package-lock.json +++ b/apps/cotizador/package-lock.json @@ -21,11 +21,14 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", "@types/qrcode": "^1.5.6", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^2.1.8", + "jsdom": "^26.1.0", "tailwindcss": "^4.0.0", "typescript": "~5.6.3", "vite": "^6.0.7", @@ -46,6 +49,27 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -344,6 +368,121 @@ "dev": true, "license": "MIT" }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1539,6 +1678,61 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1778,6 +1972,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", @@ -1804,6 +2008,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2164,6 +2378,20 @@ "utrie": "^1.0.2" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2171,6 +2399,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2198,6 +2440,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2208,6 +2457,16 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2224,6 +2483,13 @@ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "2.5.9", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", @@ -2266,6 +2532,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2505,6 +2784,19 @@ "node": ">=8" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -2526,6 +2818,47 @@ "node": ">=8.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2535,6 +2868,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2629,6 +2969,46 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2972,6 +3352,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3085,6 +3475,13 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -3128,6 +3525,19 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3253,6 +3663,54 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -3317,6 +3775,13 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3443,6 +3908,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3675,6 +4167,13 @@ "node": ">=12.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -3782,6 +4281,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -5015,6 +5560,67 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5152,6 +5758,45 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/apps/cotizador/package.json b/apps/cotizador/package.json index c1d3855..0103d3d 100644 --- a/apps/cotizador/package.json +++ b/apps/cotizador/package.json @@ -24,11 +24,14 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", "@types/qrcode": "^1.5.6", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^2.1.8", + "jsdom": "^26.1.0", "tailwindcss": "^4.0.0", "typescript": "~5.6.3", "vite": "^6.0.7", diff --git a/apps/cotizador/src/components/BannerModoLocal.tsx b/apps/cotizador/src/components/BannerModoLocal.tsx new file mode 100644 index 0000000..fefd817 --- /dev/null +++ b/apps/cotizador/src/components/BannerModoLocal.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react' +import { fetchEstadoSistema } from '../services/sistema' + +const INTERVALO_POLL_MS = 60_000 + +export function useModoLocal(): boolean { + const [modoLocal, setModoLocal] = useState(false) + useEffect(() => { + let activo = true + const consultar = () => { + void fetchEstadoSistema().then((estado) => { + if (activo) setModoLocal(estado !== null && !estado.mondayOk) + }) + } + consultar() + const id = setInterval(consultar, INTERVALO_POLL_MS) + return () => { activo = false; clearInterval(id) } + }, []) + return modoLocal +} + +export default function BannerModoLocal() { + const modoLocal = useModoLocal() + if (!modoLocal) return null + return ( +

+

+ Modo local — Monday no disponible. Tus capturas se guardan y se sincronizarán automáticamente. +

+
+ ) +} diff --git a/apps/cotizador/src/pages/CotizadorPage.tsx b/apps/cotizador/src/pages/CotizadorPage.tsx index f3d5790..ae6616d 100644 --- a/apps/cotizador/src/pages/CotizadorPage.tsx +++ b/apps/cotizador/src/pages/CotizadorPage.tsx @@ -5,6 +5,7 @@ import { z } from 'zod' import QRCode from 'qrcode' import { useUnidades } from '../hooks/useUnidades' import { useCotizacion } from '../hooks/useCotizacion' +import BannerModoLocal from '../components/BannerModoLocal' import { UnitSelector } from '../components/UnitSelector' import { UnitInfoCard } from '../components/UnitInfoCard' import { ProposalCard } from '../components/ProposalCard' @@ -300,6 +301,7 @@ export default function CotizadorPage() { return (
+
{/* Encabezado de página */}
diff --git a/apps/cotizador/src/services/sistema.ts b/apps/cotizador/src/services/sistema.ts new file mode 100644 index 0000000..2515cf4 --- /dev/null +++ b/apps/cotizador/src/services/sistema.ts @@ -0,0 +1,19 @@ +import { conAuth, getToken } from './auth' + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api' + +export interface EstadoSistema { + mondayOk: boolean + pendientesSync: number +} + +export async function fetchEstadoSistema(): Promise { + if (!getToken()) return null + try { + const res = await fetch(`${API_URL}/sistema/estado`, conAuth()) + if (!res.ok) return null + return (await res.json()) as EstadoSistema + } catch { + return null + } +} diff --git a/apps/dashboards/package-lock.json b/apps/dashboards/package-lock.json index 83c22af..fbcdf8c 100644 --- a/apps/dashboards/package-lock.json +++ b/apps/dashboards/package-lock.json @@ -15,15 +15,39 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.1.0", "tailwindcss": "^4.0.0", "typescript": "~5.6.3", "vite": "^6.0.7", "vitest": "^2.1.8" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -315,6 +339,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1516,6 +1655,61 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1758,6 +1952,49 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1889,6 +2126,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2016,6 +2267,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2034,6 +2299,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -2050,6 +2322,16 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2060,6 +2342,13 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -2091,6 +2380,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2235,6 +2537,60 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2244,6 +2600,13 @@ "node": ">=12" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2260,6 +2623,46 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2594,6 +2997,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2640,6 +3053,13 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2649,6 +3069,19 @@ "node": ">=0.10.0" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -2715,6 +3148,28 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -2732,6 +3187,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -2879,6 +3344,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2926,6 +3418,13 @@ "dev": true, "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -3014,6 +3513,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -4252,6 +4797,67 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4269,6 +4875,45 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/apps/dashboards/package.json b/apps/dashboards/package.json index 67b171c..637591f 100644 --- a/apps/dashboards/package.json +++ b/apps/dashboards/package.json @@ -17,9 +17,12 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.1.0", "tailwindcss": "^4.0.0", "typescript": "~5.6.3", "vite": "^6.0.7", diff --git a/apps/dashboards/src/components/BannerModoLocal.tsx b/apps/dashboards/src/components/BannerModoLocal.tsx new file mode 100644 index 0000000..466067c --- /dev/null +++ b/apps/dashboards/src/components/BannerModoLocal.tsx @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react' +import { fetchEstadoSistema, type EstadoSistema } from '../services/sistema' + +const INTERVALO_POLL_MS = 60_000 + +export function useEstadoSistema(): EstadoSistema | null | undefined { + const [estado, setEstado] = useState(undefined) + useEffect(() => { + let activo = true + const consultar = () => { + void fetchEstadoSistema().then((e) => { if (activo) setEstado(e) }) + } + consultar() + const id = setInterval(consultar, INTERVALO_POLL_MS) + return () => { activo = false; clearInterval(id) } + }, []) + return estado +} + +export function useModoLocal(): boolean { + const estado = useEstadoSistema() + return estado !== undefined && estado !== null && !estado.mondayOk +} + +export default function BannerModoLocal({ parcial }: { parcial?: boolean }) { + const modoLocal = useModoLocal() + if (!modoLocal && !parcial) return null + return ( +
+

+ {modoLocal + ? 'Modo local — Monday no disponible. Tus capturas se guardan y se sincronizarán automáticamente.' + : 'Datos parciales (modo local)'} +

+
+ ) +} diff --git a/apps/dashboards/src/pages/DashboardPage.tsx b/apps/dashboards/src/pages/DashboardPage.tsx index 82894d9..fabdc43 100644 --- a/apps/dashboards/src/pages/DashboardPage.tsx +++ b/apps/dashboards/src/pages/DashboardPage.tsx @@ -4,6 +4,7 @@ import TableroGeneral from '../components/TableroGeneral' import type { AccionesTablero } from '../components/comunes' import { esRolConEquipo } from '../lib/mi-tablero' import { getMe, getSesion } from '../services/auth' +import BannerModoLocal from '../components/BannerModoLocal' type Vista = 'mia' | 'general' @@ -43,6 +44,7 @@ export default function DashboardPage() { return (
+
diff --git a/apps/dashboards/src/services/sistema.ts b/apps/dashboards/src/services/sistema.ts new file mode 100644 index 0000000..2515cf4 --- /dev/null +++ b/apps/dashboards/src/services/sistema.ts @@ -0,0 +1,19 @@ +import { conAuth, getToken } from './auth' + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api' + +export interface EstadoSistema { + mondayOk: boolean + pendientesSync: number +} + +export async function fetchEstadoSistema(): Promise { + if (!getToken()) return null + try { + const res = await fetch(`${API_URL}/sistema/estado`, conAuth()) + if (!res.ok) return null + return (await res.json()) as EstadoSistema + } catch { + return null + } +} diff --git a/apps/landing/src/App.tsx b/apps/landing/src/App.tsx index 04b382f..75c6cbe 100644 --- a/apps/landing/src/App.tsx +++ b/apps/landing/src/App.tsx @@ -3,6 +3,7 @@ import { MODULOS, esModuloVisible } from './data/modulos' import { Navbar } from './components/Navbar' import { TarjetaModulo } from './components/TarjetaModulo' import { ConfiguracionCuenta } from './components/ConfiguracionCuenta' +import BannerModoLocal from './components/BannerModoLocal' import { VistaContactos } from './components/VistaContactos' import { getToken } from './services/auth' import { getMe } from './services/api' @@ -40,6 +41,7 @@ export default function App() { return (
+ setVista('home')} />
) @@ -48,6 +50,7 @@ export default function App() { return (
+
diff --git a/apps/landing/src/components/BannerModoLocal.tsx b/apps/landing/src/components/BannerModoLocal.tsx new file mode 100644 index 0000000..fefd817 --- /dev/null +++ b/apps/landing/src/components/BannerModoLocal.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react' +import { fetchEstadoSistema } from '../services/sistema' + +const INTERVALO_POLL_MS = 60_000 + +export function useModoLocal(): boolean { + const [modoLocal, setModoLocal] = useState(false) + useEffect(() => { + let activo = true + const consultar = () => { + void fetchEstadoSistema().then((estado) => { + if (activo) setModoLocal(estado !== null && !estado.mondayOk) + }) + } + consultar() + const id = setInterval(consultar, INTERVALO_POLL_MS) + return () => { activo = false; clearInterval(id) } + }, []) + return modoLocal +} + +export default function BannerModoLocal() { + const modoLocal = useModoLocal() + if (!modoLocal) return null + return ( +
+

+ Modo local — Monday no disponible. Tus capturas se guardan y se sincronizarán automáticamente. +

+
+ ) +} diff --git a/apps/landing/src/services/sistema.ts b/apps/landing/src/services/sistema.ts new file mode 100644 index 0000000..7340616 --- /dev/null +++ b/apps/landing/src/services/sistema.ts @@ -0,0 +1,17 @@ +import { API_URL, conAuth, getToken } from './auth' + +export interface EstadoSistema { + mondayOk: boolean + pendientesSync: number +} + +export async function fetchEstadoSistema(): Promise { + if (!getToken()) return null + try { + const res = await fetch(`${API_URL}/sistema/estado`, conAuth()) + if (!res.ok) return null + return (await res.json()) as EstadoSistema + } catch { + return null + } +} diff --git a/apps/mi-dia/package-lock.json b/apps/mi-dia/package-lock.json index 2b454bc..af0c2b9 100644 --- a/apps/mi-dia/package-lock.json +++ b/apps/mi-dia/package-lock.json @@ -14,15 +14,39 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.1.0", "tailwindcss": "^4.0.0", "typescript": "~5.6.3", "vite": "^6.0.7", "vitest": "^2.1.8" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -257,6 +281,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -305,6 +339,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1506,6 +1655,61 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1685,6 +1889,49 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1807,6 +2054,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1814,6 +2075,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1832,6 +2107,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1842,6 +2124,16 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1852,6 +2144,13 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.416", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz", @@ -1873,6 +2172,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2002,6 +2314,67 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2019,6 +2392,46 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2335,6 +2748,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2381,6 +2804,26 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -2447,6 +2890,31 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -2468,6 +2936,13 @@ "react": "^19.2.8" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2524,6 +2999,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2571,6 +3073,13 @@ "dev": true, "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2653,6 +3162,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -3869,6 +4424,67 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3886,6 +4502,45 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/apps/mi-dia/package.json b/apps/mi-dia/package.json index d0bcdf3..db840a3 100644 --- a/apps/mi-dia/package.json +++ b/apps/mi-dia/package.json @@ -16,9 +16,12 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.1.0", "tailwindcss": "^4.0.0", "typescript": "~5.6.3", "vite": "^6.0.7", diff --git a/apps/mi-dia/src/components/BannerModoLocal.test.tsx b/apps/mi-dia/src/components/BannerModoLocal.test.tsx new file mode 100644 index 0000000..fd6a018 --- /dev/null +++ b/apps/mi-dia/src/components/BannerModoLocal.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import BannerModoLocal from './BannerModoLocal' + +type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise + +/** Stub sencillo de localStorage (jsdom no lo expone como global con vitest 2) */ +function crearStorageStub(): Storage { + const mapa = new Map() + return { + getItem: (k: string) => mapa.get(k) ?? null, + setItem: (k: string, v: string) => void mapa.set(k, v), + removeItem: (k: string) => void mapa.delete(k), + clear: () => mapa.clear(), + key: () => null, + get length() { + return mapa.size + }, + } as Storage +} + +const sesionFake = { + token: 'jwt-de-prueba', + usuario: { nombre: 'ERICK PEÑA', rol: 'vendedor', asignacion: null }, +} + +const respuestaOk = (data: unknown) => + new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }) + +const TEXTO_BANNER = /Modo local — Monday no disponible/ + +let fetchMock: ReturnType> + +beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('localStorage', crearStorageStub()) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('BannerModoLocal', () => { + it('se muestra cuando /sistema/estado reporta Monday caído', async () => { + localStorage.setItem('frasa_auth', JSON.stringify(sesionFake)) + fetchMock.mockResolvedValue(respuestaOk({ mondayOk: false, pendientesSync: 3 })) + + render() + + expect(await screen.findByText(TEXTO_BANNER)).toBeTruthy() + expect(fetchMock).toHaveBeenCalledOnce() + expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/sistema/estado') + }) + + it('no se muestra cuando Monday está bien', async () => { + localStorage.setItem('frasa_auth', JSON.stringify(sesionFake)) + fetchMock.mockResolvedValue(respuestaOk({ mondayOk: true, pendientesSync: 0 })) + + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) + expect(screen.queryByText(TEXTO_BANNER)).toBeNull() + }) + + it('no se muestra si la consulta falla (el banner nunca rompe la app)', async () => { + localStorage.setItem('frasa_auth', JSON.stringify(sesionFake)) + fetchMock.mockRejectedValue(new TypeError('fetch failed')) + + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) + expect(screen.queryByText(TEXTO_BANNER)).toBeNull() + }) + + it('sin sesión no consulta el API ni se muestra', async () => { + render() + + await new Promise((r) => setTimeout(r, 20)) + expect(fetchMock).not.toHaveBeenCalled() + expect(screen.queryByText(TEXTO_BANNER)).toBeNull() + }) +}) diff --git a/apps/mi-dia/src/components/BannerModoLocal.tsx b/apps/mi-dia/src/components/BannerModoLocal.tsx new file mode 100644 index 0000000..b90e471 --- /dev/null +++ b/apps/mi-dia/src/components/BannerModoLocal.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react' +import { fetchEstadoSistema } from '../services/sistema' + +const INTERVALO_POLL_MS = 60_000 + +/** true mientras Monday esté caído (modo local); poll al montar y cada 60 s */ +export function useModoLocal(): boolean { + const [modoLocal, setModoLocal] = useState(false) + + useEffect(() => { + let activo = true + const consultar = () => { + void fetchEstadoSistema().then((estado) => { + if (activo) setModoLocal(estado !== null && !estado.mondayOk) + }) + } + consultar() + const id = setInterval(consultar, INTERVALO_POLL_MS) + return () => { + activo = false + clearInterval(id) + } + }, []) + + return modoLocal +} + +/** + * Aviso discreto de "modo local": solo aparece cuando /sistema/estado reporta + * Monday caído. No bloquea; todo sigue funcionando contra el espejo local. + */ +export default function BannerModoLocal() { + const modoLocal = useModoLocal() + if (!modoLocal) return null + return ( +
+

+ Modo local — Monday no disponible. Tus capturas se guardan y se sincronizarán automáticamente. +

+
+ ) +} diff --git a/apps/mi-dia/src/components/ChipPendienteSync.tsx b/apps/mi-dia/src/components/ChipPendienteSync.tsx new file mode 100644 index 0000000..ffb338a --- /dev/null +++ b/apps/mi-dia/src/components/ChipPendienteSync.tsx @@ -0,0 +1,12 @@ +/** + * Badge "⏳ por sincronizar": el item se creó en modo local y aún no sube a + * Monday (el API lo marca con pendienteSync: true). Las acciones funcionan + * igual: el backend rutea los ids locales (loc_*). + */ +export default function ChipPendienteSync() { + return ( + + ⏳ por sincronizar + + ) +} diff --git a/apps/mi-dia/src/lib/mi-dia.ts b/apps/mi-dia/src/lib/mi-dia.ts index fc60dcd..c79b6cd 100644 --- a/apps/mi-dia/src/lib/mi-dia.ts +++ b/apps/mi-dia/src/lib/mi-dia.ts @@ -12,6 +12,8 @@ export interface LeadMiDia { fuente: 'lead' | 'oportunidad' /** Último evento del activity log del board; null si el item no tiene eventos */ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null + /** true cuando el item se creó en modo local y aún no se sincroniza a Monday */ + pendienteSync?: boolean } export interface ActividadMiDia { @@ -38,6 +40,8 @@ export interface CapturaMiDia { estado: string // estado actual en Monday; '' si el lead ya no existe en el board origen: string capturadoEn: string // ISO + /** true cuando la captura se creó en modo local y aún no se sincroniza a Monday */ + pendienteSync?: boolean } export interface ClienteMiDia { @@ -63,6 +67,8 @@ export interface OportunidadMiDia { unidadCodigo: string updatedAt: string ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null + /** true cuando el item se creó en modo local y aún no se sincroniza a Monday */ + pendienteSync?: boolean } /** Apartado asignado al vendedor (tab "Mis apartados") */ @@ -73,6 +79,8 @@ export interface ApartadoMiDia { estado: string fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene) ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null + /** true cuando el item se creó en modo local y aún no se sincroniza a Monday */ + pendienteSync?: boolean } export interface ResumenMiDia { diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index 7dae4fa..7f61676 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useState, type FormEvent } from 'react' import KpiCard from '../components/KpiCard' +import BannerModoLocal from '../components/BannerModoLocal' +import ChipPendienteSync from '../components/ChipPendienteSync' import type { AccionesSesion } from '../components/AuthGate' import { esActividadVencida, @@ -187,7 +189,10 @@ function FilaOportunidad({
  • -

    {op.nombre}

    +

    + {op.nombre} + {op.pendienteSync === true && } +

    {op.telefono || 'Sin teléfono'} {op.desarrollo ? ` · ${op.desarrollo}` : ''} @@ -285,10 +290,11 @@ function FilaApartado({

  • -

    +

    {apartado.nombre} + {apartado.pendienteSync === true && } {apartado.unidadCodigo ? ( - {apartado.unidadCodigo} + {apartado.unidadCodigo} ) : null}

    @@ -510,6 +516,7 @@ function FilaLead({

    {lead.nombre} + {lead.pendienteSync === true && } {/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}

  • + +

    {nombreVendedor}

    @@ -884,7 +893,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm" >
    -

    {c.nombre}

    +

    + {c.nombre} + {c.pendienteSync === true && } +

    {c.telefono || 'Sin teléfono'} {c.origen ? ` · ${c.origen}` : ''} diff --git a/apps/mi-dia/src/services/sistema.ts b/apps/mi-dia/src/services/sistema.ts new file mode 100644 index 0000000..3a3e066 --- /dev/null +++ b/apps/mi-dia/src/services/sistema.ts @@ -0,0 +1,25 @@ +// Estado del sistema (GET /sistema/estado): salud de Monday y cola de sincronización +import { conAuth, getToken } from './auth' + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api' + +export interface EstadoSistema { + mondayOk: boolean + pendientesSync: number +} + +/** + * GET /sistema/estado. Devuelve null si no hay sesión o si la consulta falla: + * el banner de modo local es informativo y nunca debe romper la app (ni + * provocar logout/reload por un 401 del poll). + */ +export async function fetchEstadoSistema(): Promise { + if (!getToken()) return null + try { + const res = await fetch(`${API_URL}/sistema/estado`, conAuth()) + if (!res.ok) return null + return (await res.json()) as EstadoSistema + } catch { + return null + } +}