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:
391
api-gateway/src/services/monday.service.ts
Normal file
391
api-gateway/src/services/monday.service.ts
Normal 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()
|
||||
|
||||
Reference in New Issue
Block a user