feat: API Gateway (Express 5 + Prisma + Monday) y conexion de apps al API

- api-gateway: servicio Monday (retry 429/complejidad, paginacion cursor,
  column IDs por titulo), endpoints unidades/cotizaciones/clientes/planes,
  webhook con handshake challenge, Prisma SQLite dev, 55 tests
- cotizador: inventario desde /api/unidades con fallback a seed
- alta-clientes: POST /api/clientes + upload multipart de documentos
- cobranza: persistencia via API con respaldo offline en localStorage
- .env.example con board IDs del workspace Frasa-CAS
This commit is contained in:
2026-08-21 23:39:17 +00:00
parent ecd4edac02
commit 0546f35d2a
48 changed files with 7129 additions and 66 deletions

View File

@@ -0,0 +1,391 @@
import { config } from '../config/index.js'
import type { UnidadCotizable } from './cotizacion.service.js'
import { AppError } from '../utils/errors.js'
/** Unidad tal como la consume el API (y el frontend del cotizador) */
export type Unidad = UnidadCotizable
interface ColumnaMeta {
id: string
title: string
type: string
}
interface ColumnValue {
id: string
text: string | null
value: string | null
}
interface ItemMonday {
id: string
name: string
column_values: ColumnValue[]
}
interface GraphQLErrorItem {
message: string
extensions?: { code?: string }
}
interface GraphQLResponse<T> {
data?: T
errors?: GraphQLErrorItem[]
error_code?: string
error_message?: string
}
/** Error retryable de Monday (rate limit / presupuesto de complejidad / 5xx) */
class MondayRetryableError extends Error {}
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
const CACHE_UNIDADES_MS = 5 * 60 * 1000 // 5 minutos
export interface MondayServiceOptions {
apiKey?: string
apiUrl?: string
maxReintentos?: number
baseDelayMs?: number
}
export class MondayService {
private readonly apiKey: string
private readonly apiUrl: string
private readonly maxReintentos: number
private readonly baseDelayMs: number
// Cache de metadatos de columnas: boardId -> (título -> meta)
private readonly columnasCache = new Map<string, Map<string, ColumnaMeta>>()
// Cache de unidades + mapa codigo -> itemId (para escrituras)
private unidadesCache: { unidades: Unidad[]; itemIds: Map<string, string>; expira: number } | null = null
constructor(opts: MondayServiceOptions = {}) {
this.apiKey = opts.apiKey ?? config.monday.apiKey
this.apiUrl = opts.apiUrl ?? config.monday.apiUrl
this.maxReintentos = opts.maxReintentos ?? 3
this.baseDelayMs = opts.baseDelayMs ?? 500
}
/**
* Helper GraphQL contra la API de Monday.
* - Headers Authorization / API-Version.
* - Lanza en errores GraphQL.
* - Retry con backoff exponencial ante 429, errores de complejidad y 5xx
* (máx `maxReintentos` reintentos).
*/
async graphql<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
let ultimoError: Error | null = null
for (let intento = 0; intento <= this.maxReintentos; intento++) {
if (intento > 0) await sleep(this.baseDelayMs * 2 ** (intento - 1))
try {
return await this.graphqlOnce<T>(query, variables)
} catch (err) {
if (!(err instanceof MondayRetryableError)) throw err
ultimoError = err
}
}
throw new AppError(502, `Monday no respondió tras ${this.maxReintentos + 1} intentos: ${ultimoError?.message}`)
}
private async graphqlOnce<T>(query: string, variables: Record<string, unknown>): Promise<T> {
const res = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.apiKey,
'API-Version': config.monday.apiVersion,
},
body: JSON.stringify({ query, variables }),
})
if (res.status === 429) throw new MondayRetryableError('HTTP 429 (rate limit)')
if (res.status >= 500) throw new MondayRetryableError(`HTTP ${res.status}`)
if (!res.ok) throw new AppError(502, `Monday respondió HTTP ${res.status}`)
const body = (await res.json()) as GraphQLResponse<T>
// Monday devuelve errores de complejidad como 200 con error_code
if (body.error_code) {
if (/complexity/i.test(body.error_code) || /complexity/i.test(body.error_message ?? '')) {
throw new MondayRetryableError(`Complejidad excedida: ${body.error_code}`)
}
throw new AppError(502, `Error de Monday: ${body.error_code} ${body.error_message ?? ''}`)
}
if (body.errors?.length) {
const msg = body.errors.map((e) => e.message).join('; ')
const esComplejidad = body.errors.some(
(e) => /complexity/i.test(e.message) || /complexity/i.test(e.extensions?.code ?? ''),
)
if (esComplejidad) throw new MondayRetryableError(`Complejidad excedida: ${msg}`)
throw new AppError(502, `Errores GraphQL de Monday: ${msg}`)
}
if (!body.data) throw new AppError(502, 'Monday respondió sin data')
return body.data
}
/** Metadatos de columnas de un tablero, mapeadas por TÍTULO (cache en memoria) */
private async columnasDe(boardId: string): Promise<Map<string, ColumnaMeta>> {
const cached = this.columnasCache.get(boardId)
if (cached) return cached
const data = await this.graphql<{ boards: { columns: ColumnaMeta[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
{ ids: [boardId] },
)
const columnas = data.boards[0]?.columns ?? []
const mapa = new Map<string, ColumnaMeta>(columnas.map((c) => [c.title, c]))
this.columnasCache.set(boardId, mapa)
return mapa
}
/** Resuelve el column ID por título exacto; lanza si no existe en el tablero */
private async columnaId(boardId: string, titulo: string): Promise<ColumnaMeta> {
const cols = await this.columnasDe(boardId)
const col = cols.get(titulo)
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero ${boardId}`)
return col
}
/** Ping barato para /api/health */
async ping(): Promise<boolean> {
try {
const data = await this.graphql<{ boards: { id: string }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { id } }`,
{ ids: [config.monday.boards.unidades] },
)
return (data.boards?.length ?? 0) > 0
} catch {
return false
}
}
/** Invalida el cache de unidades (llamar tras cualquier escritura que las afecte) */
invalidarCacheUnidades(): void {
this.unidadesCache = null
}
/** Todas las unidades del tablero, con paginación por cursor y cache de 5 min */
async getUnidades(): Promise<Unidad[]> {
const ahora = Date.now()
if (this.unidadesCache && this.unidadesCache.expira > ahora) {
return this.unidadesCache.unidades
}
const boardId = config.monday.boards.unidades
const cols = await this.columnasDe(boardId)
const items: ItemMonday[] = []
let cursor: string | null = null
do {
// La primera página no lleva cursor; las siguientes sí (nunca asumir una sola página)
const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } =
cursor === null
? await this.graphql(
`query ($ids: [ID!]) {
boards(ids: $ids) {
items_page(limit: 500) { cursor items { id name column_values { id text value } } }
}
}`,
{ ids: [boardId] },
)
: await this.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) {
items_page(limit: 500, cursor: $cursor) { cursor items { id name column_values { id text value } } }
}
}`,
{ ids: [boardId], cursor },
)
const page = data.boards[0]?.items_page
if (!page) break
items.push(...page.items)
cursor = page.cursor
} while (cursor !== null)
const itemIds = new Map<string, string>()
const unidades: Unidad[] = []
for (const item of items) {
const unidad = this.mapearUnidad(item, cols)
// Items placeholder (sin modelo ni precios, p. ej. el "Task 1" default de
// Monday) no son inventario: se excluyen del listado
if (unidad.modelo === '' && unidad.precioSinAcabados === 0 && unidad.precioConAcabados === 0) continue
itemIds.set(item.name, item.id)
unidades.push(unidad)
}
this.unidadesCache = { unidades, itemIds, expira: ahora + CACHE_UNIDADES_MS }
return unidades
}
/** Una unidad por código (U-101, U-310...); null si no existe */
async getUnidad(codigo: string): Promise<Unidad | null> {
const unidades = await this.getUnidades()
return unidades.find((u) => u.codigo === codigo) ?? null
}
private mapearUnidad(item: ItemMonday, cols: Map<string, ColumnaMeta>): Unidad {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
const textoDe = (titulo: string): string => {
const col = cols.get(titulo)
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero Unidades`)
return porId.get(col.id)?.text ?? ''
}
const numeroDe = (titulo: string): number => {
const n = Number(textoDe(titulo))
return Number.isFinite(n) ? n : 0
}
const status = textoDe('Disponibilidad').toUpperCase()
return {
codigo: item.name,
modelo: textoDe('Modelo'),
fechaEntrega: textoDe('Fecha entrega'),
supEstacionamiento: numeroDe('Sup. estacionamiento'),
supConstruccion: numeroDe('Sup. construcción'),
supExcedente: numeroDe('Sup. excedente'),
precioSinAcabados: numeroDe('Precio sin acabados'),
precioConAcabados: numeroDe('Precio con acabados'),
status: status === 'APARTADA' || status === 'VENDIDA' ? status : 'DISPONIBLE',
desarrollo: textoDe('Desarrollo'),
}
}
/**
* Serializa un valor crudo al formato que espera el tipo de columna de Monday.
* column_values SIEMPRE se envía con JSON.stringify en variables (nunca
* template literals dentro del query).
*/
private valorPorTipo(col: ColumnaMeta, crudo: string | number): unknown {
switch (col.type) {
case 'status':
return { label: String(crudo) }
case 'date':
return { date: String(crudo) }
case 'numbers':
return typeof crudo === 'number' ? crudo : Number(crudo)
case 'email':
return { email: String(crudo), text: String(crudo) }
case 'phone':
return { phone: String(crudo), countryShortName: 'MX' }
default:
return String(crudo)
}
}
private async construirColumnValues(
boardId: string,
valores: Record<string, string | number | undefined>,
): Promise<string> {
const out: Record<string, unknown> = {}
for (const [titulo, crudo] of Object.entries(valores)) {
if (crudo === undefined || crudo === '') continue
const col = await this.columnaId(boardId, titulo)
out[col.id] = this.valorPorTipo(col, crudo)
}
return JSON.stringify(out)
}
private async crearItem(boardId: string, nombre: string, columnValues: string): Promise<string> {
const data = await this.graphql<{ create_item: { id: string } }>(
`mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) {
create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues) { id }
}`,
{ boardId, itemName: nombre, columnValues },
)
return data.create_item.id
}
// ---------- Escrituras de negocio ----------
async crearOportunidad(datos: DatosOportunidad): Promise<string> {
const boardId = config.monday.boards.oportunidades
const columnValues = await this.construirColumnValues(boardId, {
Unidad: datos.unidadCodigo,
'Cliente email': datos.clienteEmail,
'Cliente teléfono': datos.clienteTelefono,
'Tipo propuesta': datos.tipoPropuestaLabel,
'Precio de lista': datos.precioLista,
'Descuento %': datos.descuentoPct,
'Precio final': datos.precioFinal,
'Fecha cotización': datos.fechaCotizacion,
Estado: datos.estadoLabel,
})
return this.crearItem(boardId, datos.clienteNombre, columnValues)
}
async crearContacto(datos: DatosContacto): Promise<string> {
const boardId = config.monday.boards.contactos
const columnValues = await this.construirColumnValues(boardId, {
Teléfono: datos.telefono,
Email: datos.email,
CURP: datos.curp,
RFC: datos.rfc,
Dirección: datos.direccion,
Estado: datos.estadoLabel,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
async crearApartado(datos: DatosApartado): Promise<string> {
const boardId = config.monday.boards.apartado
const columnValues = await this.construirColumnValues(boardId, {
Unidad: datos.unidadCodigo,
'Monto apartado': datos.montoApartado,
'Fecha apartado': datos.fechaApartado,
'Fecha límite': datos.fechaLimite,
Estado: datos.estadoLabel,
})
return this.crearItem(boardId, datos.clienteNombre, columnValues)
}
async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise<void> {
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()
}
}
// ---------- DTOs de escritura ----------
export interface DatosOportunidad {
clienteNombre: string
unidadCodigo: string
tipoPropuestaLabel: string
precioLista: number
descuentoPct: number // porcentaje (2 = 2%)
precioFinal: number
fechaCotizacion: string // 'YYYY-MM-DD'
estadoLabel: string // p. ej. 'COTIZADA'
clienteEmail?: string
clienteTelefono?: string
}
export interface DatosContacto {
nombre: string
telefono?: string
email?: string
curp?: string
rfc?: string
direccion?: string
estadoLabel: string // p. ej. 'CLIENTE'
}
export interface DatosApartado {
clienteNombre: string
unidadCodigo: string
montoApartado?: number
fechaApartado: string // 'YYYY-MM-DD'
fechaLimite: string // 'YYYY-MM-DD'
estadoLabel: string // p. ej. 'VIGENTE'
}
/** Singleton usado por los controllers (los tests instancian el suyo) */
export const mondayService = new MondayService()