Files
FRASA-CAS/api-gateway
Consult AS Technology 50e6eca7da fix(mi-dia): dedup capturas que ya aparecen en leads/oportunidades
Cuando un lead se crea con Monday activo, aparece tanto en el tablero
Monday (misLeads) como en LeadCaptura (misCapturas). Filtrar de
'misCapturas' los items cuyo leadId ya existe en misLeads/misOportunidades
para evitar el duplicado visual que reportan los vendedores.

597 tests verdes.
2026-09-06 08:26:03 +00:00
..

FRASA API Gateway

Backend Node.js que conecta las 3 apps web del ecosistema FRASA (cotizador :3001, alta-clientes :3002, cobranza :3003) con Monday.com y da persistencia real (Prisma + SQLite en dev, Postgres 16 en prod).

Stack

Express 5 · TypeScript estricto · zod · Prisma (SQLite dev) · multer 2 · vitest

Cómo correr en dev

npm install
npx prisma migrate dev   # crea/actualiza prisma/dev.db
npm run dev              # tsx watch en :3000

Las credenciales de Monday y los board IDs se leen del .env raíz del ecosistema (../.env); el servicio también acepta un .env local (Prisma CLI lo requiere para DATABASE_URL). El token NUNCA se imprime en logs.

Tests

npm test   # vitest: fórmulas del cotizador (fixture U-101), cobranza, monday.service con fetch mockeado

Endpoints (prefijo /api)

Método Ruta Descripción
GET /api/health `{status, monday: 'up'
GET /api/unidades Inventario desde Monday (?disponibles=true filtra; ?desarrollo= filtra por DIAGONAL/ATELIER/ESTANZA/SENDA/SOLANA)
GET /api/unidades/:codigo Una unidad (404 si no existe)
POST /api/cotizaciones Calcula propuesta (server-side), persiste y crea Oportunidad en Monday. Asocia vendedorId = sub del JWT
GET /api/cotizaciones · /api/cotizaciones/:id Consulta
GET /api/cotizaciones/mias Últimas 50 del vendedor autenticado (más recientes primero)
GET /api/cotizaciones/:id/publico Público (sin auth), destino del QR del PDF: folio, desarrollo, unidad, propuesta, montos, vigencia y nombre del vendedor. NUNCA datos de contacto del cliente
POST /api/cotizaciones/alertas-inventario Alerta de cambio de estatus (ver abajo)
POST /api/clientes Alta de cliente: persiste payload completo, crea Contacto y Apartado en Monday
GET /api/clientes/:id Cliente con documentos
POST /api/clientes/:id/documentos Multipart (campo archivos, máx 10, pdf/jpg/png, 10 MB)
GET /api/clientes/:id/documentos Lista documentos
POST /api/planes Crea plan de pago con calendario generado
GET /api/planes · /api/planes/:id Con saldo/status calculados
GET /api/planes/cobranza-del-dia Urgencias de cobranza (?diasAnticipacion=7)
POST /api/planes/:id/abonos Aplica abono (estricto al centavo)
POST /api/planes/:id/notas Agrega nota a la bitácora
POST /api/planes/:id/cancelar Cancela el plan
PATCH /api/planes/:id/monday Liga/desliga el item del tablero Apartado ({mondayItemId}; al liquidarse el plan, ese item pasa a X Escriturar)
POST /api/oportunidades/:mondayItemId/perder Etapa Perdido + dropdown motivo; registra en el tablero Motivos No Compra ({motivo, comentario?})
POST /api/webhooks/monday Handshake challenge; create_item en Leads dispara round-robin
GET /api/automatizaciones/estado Índice RR actual, vendedores, último escaneo
POST /api/automatizaciones/round-robin/run Pasada manual de asignación round-robin
GET /api/automatizaciones/inactividad/preview Leads que serían recordados hoy (solo lectura)

Automatizaciones de Monday (tablero Leads)

  • Round-robin de vendedores: leads sin persona en la columna people Vendedor reciben el siguiente vendedor de la lista rotativa. La lista viene de MONDAY_RR_USER_IDS (coma-separados) o, si está vacía, de los usuarios no-guest del workspace (cacheados en BD). El índice se persiste en la tabla AutomatizacionState (sobrevive reinicios).
  • Recordatorio de inactividad: leads con estado no terminal (DESCARTADO y ABANDONO son terminales) y más de INACTIVIDAD_DIAS (default 10) sin actualizarse reciben un update en el item. Dedup por día: clave recordatorio:<itemId>:<fecha> en AutomatizacionState.
  • Actividades automáticas (tablero Actividades): al asignar vendedor se crea "Contactar a {lead}" (tipo Llamada, hoy+1, Abierto) y el recordatorio de inactividad crea "Retomar contacto con {lead}" al vendedor asignado. Se apagan con ACTIVIDADES_ENABLED=false; un fallo al crear la actividad no rompe la asignación.
  • Setup CRM (npm run setup:crm): crea/alinea tableros, columnas y labels del vocabulario CRM FRASA (idempotente). Sync de desarrollos (npm run sync:desarrollos): recalcula Precio Desde/Hasta, M2, entrega y modelos del tablero Desarrollos desde los inventarios reales.
  • Sync de etapas (npm run sync:etapas -- [--archivo <path>] [--dry-run] [--semilla]): sincroniza la columna status Etapa del tablero Unidades (pipeline de 10 etapas: Apartado → Reserva → Vendido → Integración → En Análisis → Autorizado → Notaría → Escriturado → Entrega → PostVenta) desde un JSON [{codigo, etapa}] (default: el maestro reference/maestro/maestro-unidades.json). Idempotente; al llegar a Vendido/Escriturado crea la actividad de traspaso de departamento (a Escrituración / a SAC) con dedup traspaso:<codigo>:<etapa> en AutomatizacionState. En la PRIMERA corrida (carga histórica) usar --semilla: registra el dedup sin crear actividades retroactivas.
  • Disparadores: webhook POST /api/webhooks/monday (evento create_item en el board Leads) y, como respaldo mientras no haya URL pública, crons con node-cron: POLL_CRON (default */5 * * * *) para round-robin y 0 9 * * * para inactividad. Los crons se apagan con MONDAY_AUTOMATIONS_ENABLED=false.

Alerta de inventario (cambio de estatus de unidad)

POST /api/cotizaciones/alertas-inventario con body {"cambios": [{"unidadCodigo": "U-101", "statusNuevo": "APARTADO"}]} (máx 100). Por cada unidad busca cotizaciones de los últimos 30 días y crea una Actividad en Monday al vendedor de cada una ("La unidad X que cotizaste pasó a APARTADO", tipo Tarea, Abierto), solo si el vendedor tiene mondayUserId mapeado. Dedup persistente por unidad+cotización+estatus (clave alerta-inv:<unidad>:<cotizacionId>:<status> en AutomatizacionState); si Monday falla, esa alerta NO queda deduplicada y el siguiente sync la reintenta. Responde {cambiosProcesados, alertasCreadas, omitidas, fallidas}.

Cómo invocarlo desde el sync: al final de sync-etapas.ts / sync-estatus-maestro.py, recolectar los cambios de estatus aplicados y hacer un POST autenticado (Bearer JWT):

curl -X POST "$API_URL/api/cotizaciones/alertas-inventario" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"cambios":[{"unidadCodigo":"U-101","statusNuevo":"APARTADO"}]}'

(Wire directo pendiente: los syncs actuales no reportan su lista de cambios; por eso queda como endpoint invocable.)

Notas de arquitectura

  • Monday service (src/services/monday.service.ts): resuelve column IDs por TÍTULO en runtime (los slugs autogenerados nunca se hardcodean), paginación por cursor, retry con backoff ante 429/complejidad, column_values siempre via JSON.stringify en variables, cache de unidades 5 min con invalidación tras escrituras.
  • Cálculos del cotizador (src/services/cotizacion.service.ts): port exacto del Excel de producción (quirks incluidos: Infonavit usa precio de lista en mensualidades; enganche bancario sobre precio lista). NO corregir.
  • Cobranza (src/services/cobranza.service.ts): espejo de la lógica del frontend; los controllers mapean Prisma → dominio, ejecutan las funciones puras y persisten.
  • Dinero como Float por ahora — TODO prod: Decimal en Postgres.
  • Webhooks de Monday no están firmados → el payload se trata como no confiable; el evento solo es un disparador y el round-robin re-consulta el tablero vía API antes de escribir.

TODO producción (pendientes deliberados)

  • Auth (JWT ya hay vars en .env), helmet, rate-limit — es red interna por ahora.
  • Postgres 16 (provider = "postgresql", Decimal), Redis para cache de unidades.
  • CORS restringido a los orígenes de las apps.

Docker

docker build -t frasa-api-gateway .
docker run -p 3000:3000 --env-file ../.env frasa-api-gateway
# El arranque corre `prisma migrate deploy` antes de levantar el server