Files
FRASA-CAS/api-gateway/src/services/monday.service.ts
Consult AS Technology 57029bdd3d feat(afluencia): el campo de interes muestra los modelos del desarrollo
'Al departamento o casa de interes' ahora es un select dependiente:
al elegir desarrollo se cargan sus modelos (MODELOS_POR_PROYECTO) y al
cambiar de desarrollo se resetea. En Monday la columna 'Tipo de
Propiedad' se renombro a 'Modelo de Interes' con los 16 modelos como
labels del status.
2026-09-02 01:48:45 +00:00

1114 lines
42 KiB
TypeScript

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 ItemConFechas extends ItemMonday {
created_at: string
updated_at: string
}
/** Lead del tablero Leads tal como lo consumen las automatizaciones */
export interface LeadMonday {
id: string
nombre: string
createdAt: string // ISO (created_at del item en Monday)
updatedAt: string // ISO (updated_at del item en Monday)
estado: string // label de la columna status 'Estado' ('' si no tiene)
origen: string // label de la columna status 'Origen' ('' si no tiene)
telefono: string // columna phone 'Teléfono' ('' si no tiene)
email: string // columna email 'Email' ('' si no tiene)
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
}
/** Actividad del tablero Actividades (agenda de seguimiento del CRM) */
export interface ActividadMonday {
id: string
nombre: string
tipo: string // label de la columna status 'Tipo' (Llamada | Reunion | Tarea | ...)
fecha: string // 'YYYY-MM-DD' de la columna date 'Fecha' ('' si no tiene)
estado: string // label de la columna status 'Estado' (Abierto | Listo | ...)
responsableIds: number[] // user IDs de la columna people 'Responsable'
}
/** Apartado del tablero Apartado (pipeline Enkontrol) */
export interface ApartadoMonday {
id: string
nombre: string // nombre del cliente
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
fechaLimite: string // 'YYYY-MM-DD' de 'Fecha límite' ('' si no tiene)
estado: string // label de la columna status 'Estado'
}
/** Oportunidad del tablero Oportunidades (pipeline comercial) */
export interface OportunidadMonday {
id: string
nombre: string // nombre del cliente
createdAt: string // ISO
etapa: string // label de la columna status 'Estado'
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
precioFinal: number // columna numbers 'Precio final' (0 si no tiene)
}
/** Usuario del workspace (para la lista rotativa de vendedores) */
export interface UsuarioMonday {
id: number
nombre: string
email: string
}
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
}
/** Item ID de una unidad por código (para escrituras); null si no existe */
async itemIdDeUnidad(codigo: string): Promise<string | null> {
await this.getUnidades() // asegura cache fresco
return this.unidadesCache?.itemIds.get(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()
// La columna "Meses para firma" es opcional (solo la usa SOLANA)
const colMeses = cols.get('Meses para firma')
const mesesTexto = colMeses ? (porId.get(colMeses.id)?.text ?? '') : ''
const mesesNum = Number(mesesTexto)
// La columna status "Etapa" (pipeline de 10 etapas) es opcional
const colEtapa = cols.get('Etapa')
const etapaTexto = colEtapa ? (porId.get(colEtapa.id)?.text ?? '') : ''
return {
codigo: item.name,
modelo: textoDe('Modelo'),
fechaEntrega: textoDe('Fecha entrega') || null,
supEstacionamiento: numeroDe('Sup. estacionamiento'),
supConstruccion: numeroDe('Sup. construcción'),
supExcedente: numeroDe('Sup. excedente'),
precioSinAcabados: numeroDe('Precio sin acabados'),
precioConAcabados: numeroDe('Precio con acabados'),
// Se conserva el status real del board (VENDIDO, ESCRITURADO, BLOQUEADO, ...)
status: status || 'DISPONIBLE',
desarrollo: textoDe('Desarrollo'),
mesesParaFirma: mesesTexto !== '' && Number.isFinite(mesesNum) ? mesesNum : null,
etapa: etapaTexto !== '' ? etapaTexto : null,
}
}
/**
* 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 | boolean): unknown {
switch (col.type) {
case 'status':
return { label: String(crudo) }
case 'dropdown':
return { labels: [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' }
case 'checkbox':
return { checked: crudo === true || crudo === 'true' ? 'true' : 'false' }
case 'people':
return { personsAndTeams: [{ id: Number(crudo), kind: 'person' }] }
case 'board_relation':
return { item_ids: [Number(crudo)] }
case 'long_text':
return { text: String(crudo) }
default:
return String(crudo)
}
}
private async construirColumnValues(
boardId: string,
valores: Record<string, string | number | boolean | 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)
}
/**
* Oportunidad en etapa 'Afluencia' a partir de un lead recién capturado
* (entrada al funnel; sin unidad ni precios todavía).
*/
async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise<string> {
const boardId = config.monday.boards.oportunidades
const columnValues = await this.construirColumnValues(boardId, {
'Cliente teléfono': datos.telefono,
'Cliente email': datos.email,
Desarrollo: datos.desarrolloLabel,
Estado: 'Afluencia',
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
/**
* Actualiza una oportunidad existente con los datos de una cotización
* (mismo builder de valores que crearOportunidad) y la mueve a 'Cotizador'.
*/
async actualizarOportunidadCotizacion(oportunidadId: string, datos: DatosOportunidad): Promise<void> {
const boardId = config.monday.boards.oportunidades
const columnValues = await this.construirColumnValues(boardId, {
Unidad: datos.unidadCodigo,
'Tipo propuesta': datos.tipoPropuestaLabel,
'Precio de lista': datos.precioLista,
'Descuento %': datos.descuentoPct,
'Precio final': datos.precioFinal,
'Fecha cotización': datos.fechaCotizacion,
Estado: datos.estadoLabel,
})
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) {
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id }
}`,
{ boardId, itemId: oportunidadId, 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,
// Perfil demográfico (vocabulario CRM FRASA); solo se envía lo capturado
Sexo: datos.sexoLabel,
'Estado Civil': datos.estadoCivilLabel,
'Rango de Edad': datos.rangoEdadLabel,
'Actividad Economica': datos.actividadEconomicaLabel,
'Vivienda Actual': datos.viviendaActualLabel,
'Proposito de Compra': datos.propositoCompraLabel,
'Fecha Nacimiento': datos.fechaNacimiento,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
/**
* Actualiza un Contacto existente con los campos presentes (mismo builder de
* valores que crearContacto) — usado por el guardado progresivo del alta.
* El nombre del item se actualiza por el column id especial 'name'.
*/
async actualizarContacto(itemId: string, datos: Partial<DatosContacto>): Promise<void> {
const boardId = config.monday.boards.contactos
const valores = JSON.parse(
await this.construirColumnValues(boardId, {
Teléfono: datos.telefono,
Email: datos.email,
CURP: datos.curp,
RFC: datos.rfc,
Dirección: datos.direccion,
Estado: datos.estadoLabel,
Sexo: datos.sexoLabel,
'Estado Civil': datos.estadoCivilLabel,
'Rango de Edad': datos.rangoEdadLabel,
'Actividad Economica': datos.actividadEconomicaLabel,
'Vivienda Actual': datos.viviendaActualLabel,
'Proposito de Compra': datos.propositoCompraLabel,
'Fecha Nacimiento': datos.fechaNacimiento,
}),
) as Record<string, unknown>
if (datos.nombre !== undefined && datos.nombre !== '') valores['name'] = datos.nombre
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) {
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id }
}`,
{ boardId, itemId, columnValues: JSON.stringify(valores) },
)
}
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()
}
// ---------- Escrituras genéricas ----------
/**
* Cambia UNA columna de un item, resolviendo el column ID por título y
* serializando según el tipo de columna (status, dropdown, checkbox, date...).
*/
async cambiarValorColumna(
boardId: string,
itemId: string,
tituloColumna: string,
valor: string | number | boolean,
): Promise<void> {
const col = await this.columnaId(boardId, tituloColumna)
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(this.valorPorTipo(col, valor)) },
)
}
/** Nombre de un item por ID (cualquier tablero) */
async obtenerNombreItem(itemId: string): Promise<string> {
const data = await this.graphql<{ items: { name: string }[] }>(
`query ($ids: [ID!]) { items(ids: $ids) { name } }`,
{ ids: [itemId] },
)
const nombre = data.items[0]?.name
if (nombre === undefined) throw new AppError(404, `Item ${itemId} no encontrado en Monday`)
return nombre
}
// ---------- Motivos No Compra / Actividades (tableros CRM FRASA) ----------
/** Item en el tablero Motivos No Compra (nombre = "<motivo> — <item origen>") */
async crearMotivoNoCompra(datos: DatosMotivoNoCompra): Promise<string> {
const boardId = config.monday.boards.motivos
if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_MOTIVOS no está configurado')
const columnValues = await this.construirColumnValues(boardId, {
Categoria: datos.categoria,
Descripcion: datos.descripcion,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
/** Actividad de seguimiento ligada a un lead (bitácora del CRM) */
async crearActividad(datos: DatosActividad): Promise<string> {
const boardId = config.monday.boards.actividades
if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_ACTIVIDADES no está configurado')
// La columna board_relation "Lead" no puede crearse vía API (limitación de
// Monday): si existe en el tablero se liga, si no, la actividad se crea igual.
let lead: string | undefined
try {
await this.columnaId(boardId, 'Lead')
lead = datos.leadItemId
} catch {
lead = undefined
}
const columnValues = await this.construirColumnValues(boardId, {
Tipo: datos.tipoLabel,
Responsable: datos.responsableId,
Fecha: datos.fecha,
Estado: datos.estadoLabel,
Lead: lead,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
// ---------- Leads (automatizaciones) ----------
/** Items del tablero Leads con estado, fecha de actualización y vendedor asignado */
async getLeads(): Promise<LeadMonday[]> {
const boardId = config.monday.boards.leads
const cols = await this.columnasDe(boardId)
const items: ItemConFechas[] = []
let cursor: string | null = null
do {
const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } =
cursor === null
? await this.graphql(
`query ($ids: [ID!]) {
boards(ids: $ids) {
items_page(limit: 200) {
cursor
items { id name created_at updated_at column_values { id text value } }
}
}
}`,
{ ids: [boardId] },
)
: await this.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) {
items_page(limit: 200, cursor: $cursor) {
cursor
items { id name created_at updated_at 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)
return items.map((item) => this.mapearLead(item, cols))
}
/** Un lead del tablero Leads por item ID (el payload del webhook no trae datos) */
async getLeadPorId(itemId: string): Promise<LeadMonday | null> {
const boardId = config.monday.boards.leads
const cols = await this.columnasDe(boardId)
const data = await this.graphql<{ items: (ItemConFechas | null)[] }>(
`query ($ids: [ID!]) {
items(ids: $ids) { id name created_at updated_at column_values { id text value } }
}`,
{ ids: [itemId] },
)
const item = data.items[0]
if (!item) return null
return this.mapearLead(item, cols)
}
private mapearLead(item: ItemConFechas, cols: Map<string, ColumnaMeta>): LeadMonday {
const colVendedor = cols.get('Vendedor')
if (!colVendedor || colVendedor.type !== 'people') {
throw new AppError(500, `Columna people "Vendedor" no existe en el tablero Leads`)
}
const colEstado = cols.get('Estado')
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
const rawVendedor = porId.get(colVendedor.id)?.value
let vendedorIds: number[] = []
if (rawVendedor) {
try {
const parsed = JSON.parse(rawVendedor) as { personsAndTeams?: { id: number; kind: string }[] }
vendedorIds = (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id)
} catch {
vendedorIds = []
}
}
const colOrigen = cols.get('Origen')
const colTelefono = cols.get('Teléfono')
const colEmail = cols.get('Email')
return {
id: item.id,
nombre: item.name,
createdAt: item.created_at,
updatedAt: item.updated_at,
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '',
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
vendedorIds,
}
}
/**
* Items del tablero Oportunidades con etapa, unidad y precio final
* (los consumen las automatizaciones Aparto/Ganado y los reportes).
*/
async getOportunidades(): Promise<OportunidadMonday[]> {
const boardId = config.monday.boards.oportunidades
const cols = await this.columnasDe(boardId)
const colEstado = cols.get('Estado')
if (!colEstado) throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`)
const colUnidad = cols.get('Unidad')
const colPrecio = cols.get('Precio final')
const items: ItemConFechas[] = []
let cursor: string | null = null
do {
const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } =
cursor === null
? await this.graphql(
`query ($ids: [ID!]) {
boards(ids: $ids) {
items_page(limit: 200) {
cursor
items { id name created_at updated_at column_values { id text value } }
}
}
}`,
{ ids: [boardId] },
)
: await this.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) {
items_page(limit: 200, cursor: $cursor) {
cursor
items { id name created_at updated_at 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)
return items.map((item) => {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
const precio = colPrecio ? Number(porId.get(colPrecio.id)?.text ?? '') : NaN
return {
id: item.id,
nombre: item.name,
createdAt: item.created_at,
etapa: porId.get(colEstado.id)?.text ?? '',
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
precioFinal: Number.isFinite(precio) ? precio : 0,
}
})
}
// ---------- Mi día (CRM ligero para vendedores) ----------
/** Parsea el value JSON de una columna people a los user IDs (solo personas) */
private peopleIds(raw: string | null | undefined): number[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw) as { personsAndTeams?: { id: number; kind: string }[] }
return (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id)
} catch {
return []
}
}
/** Todas las actividades del tablero Actividades, con tipo, fecha, estado y responsables */
async getActividades(): Promise<ActividadMonday[]> {
const boardId = config.monday.boards.actividades
if (boardId === '') return []
const cols = await this.columnasDe(boardId)
const colTipo = cols.get('Tipo')
const colFecha = cols.get('Fecha')
const colEstado = cols.get('Estado')
const colResponsable = cols.get('Responsable')
const items: ItemMonday[] = []
let cursor: string | null = null
do {
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)
return items.map((item) => {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
return {
id: item.id,
nombre: item.name,
tipo: colTipo ? (porId.get(colTipo.id)?.text ?? '') : '',
fecha: colFecha ? (porId.get(colFecha.id)?.text ?? '') : '',
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
responsableIds: colResponsable ? this.peopleIds(porId.get(colResponsable.id)?.value) : [],
}
})
}
/** Todos los apartados del tablero Apartado, con unidad, fecha límite y estado */
async getApartados(): Promise<ApartadoMonday[]> {
const boardId = config.monday.boards.apartado
const cols = await this.columnasDe(boardId)
const colUnidad = cols.get('Unidad')
const colFechaLimite = cols.get('Fecha límite')
const colEstado = cols.get('Estado')
const items: ItemMonday[] = []
let cursor: string | null = null
do {
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)
return items.map((item) => {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
return {
id: item.id,
nombre: item.name,
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
fechaLimite: colFechaLimite ? (porId.get(colFechaLimite.id)?.text ?? '') : '',
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
}
})
}
/**
* Labels de una columna status/dropdown (settings_str del board).
* Monday los devuelve como objeto {index: label} o como arreglo [{id, name}].
*/
async labelsDeColumna(boardId: string, titulo: string): Promise<string[]> {
const data = await this.graphql<{ boards: { columns: { id: string; title: string; settings_str: string }[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title settings_str } } }`,
{ ids: [boardId] },
)
const col = data.boards[0]?.columns.find((c) => c.title === titulo)
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero ${boardId}`)
try {
const settings = JSON.parse(col.settings_str || '{}') as { labels?: Record<string, string> | { name: string }[] }
const labels = settings.labels
if (Array.isArray(labels)) return labels.map((l) => l.name)
if (labels && typeof labels === 'object') return Object.values(labels)
return []
} catch {
return []
}
}
/** Item en el tablero Leads (Estado NUEVO; captura rápida del vendedor) */
async crearLead(datos: DatosLead): Promise<string> {
const boardId = config.monday.boards.leads
const columnValues = await this.construirColumnValues(boardId, {
Estado: 'NUEVO',
Teléfono: datos.telefono,
Email: datos.email,
Origen: datos.origenLabel,
Desarrollo: datos.desarrolloLabel,
Notas: datos.notas,
'Modelo de Interés': datos.modeloLabel,
'Medio de Contacto': datos.medioContacto,
Edad: datos.edad,
'Estado Civil': datos.estadoCivilLabel,
'Tipo de Empleo': datos.tipoEmpleoLabel,
Ocupación: datos.ocupacion,
'Rango Salarial': datos.rangoSalarial,
Crédito: datos.creditoLabel,
'Tipo de Economía': datos.tipoEconomiaLabel,
Califica: datos.calificaLabel,
'Lo Quiere Para': datos.loQuierePara,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
/**
* Lead con el mismo teléfono (dedup de captura rápida). Compara solo dígitos
* y los últimos 10 (tolerante a lada/prefijo 52). null si no hay duplicado.
*/
async buscarLeadPorTelefono(telefono: string): Promise<LeadMonday | null> {
const digitos = telefono.replace(/\D/g, '')
if (digitos.length < 10) return null
const clave = digitos.slice(-10)
const leads = await this.getLeads()
return (
leads.find((l) => {
const d = l.telefono.replace(/\D/g, '')
return d.length >= 10 && d.slice(-10) === clave
}) ?? null
)
}
/** Borra un item de cualquier tablero (limpieza de datos de prueba) */
async eliminarItem(itemId: string): Promise<void> {
await this.graphql<{ delete_item: { id: string } }>(
`mutation ($itemId: ID!) { delete_item(item_id: $itemId) { id } }`,
{ itemId },
)
}
/** Item en el tablero Ventas (se crea cuando una oportunidad pasa a 'Ganado') */
async crearVenta(datos: DatosVenta): Promise<string> {
const boardId = config.monday.boards.ventas
const columnValues = await this.construirColumnValues(boardId, {
Unidad: datos.unidadCodigo,
'Valor venta': datos.valorVenta,
})
return this.crearItem(boardId, datos.clienteNombre, columnValues)
}
/**
* Número de actividades con Estado 'Abierto' en el tablero Actividades.
* Si el tablero no está configurado devuelve 0 (los reportes no deben caer por eso).
*/
async getActividadesAbiertas(): Promise<number> {
const boardId = config.monday.boards.actividades
if (boardId === '') return 0
const cols = await this.columnasDe(boardId)
const colEstado = cols.get('Estado')
if (!colEstado) return 0
let abiertas = 0
let cursor: string | null = null
do {
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 column_values { id text } } }
}
}`,
{ ids: [boardId] },
)
: await this.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) {
items_page(limit: 500, cursor: $cursor) { cursor items { id column_values { id text } } }
}
}`,
{ ids: [boardId], cursor },
)
const page = data.boards[0]?.items_page
if (!page) break
for (const item of page.items) {
const estado = item.column_values.find((cv) => cv.id === colEstado.id)?.text ?? ''
if (estado.trim().toUpperCase() === 'ABIERTO') abiertas++
}
cursor = page.cursor
} while (cursor !== null)
return abiertas
}
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
const boardId = config.monday.boards.leads
const col = await this.columnaId(boardId, 'Vendedor')
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({ personsAndTeams: [{ id: userId, kind: 'person' }] }),
},
)
}
/** Crea un update (comentario) en un item — usado para los recordatorios de inactividad */
async crearUpdateEnItem(itemId: string, body: string): Promise<string> {
const data = await this.graphql<{ create_update: { id: string } }>(
`mutation ($itemId: ID!, $body: String!) {
create_update(item_id: $itemId, body: $body) { id }
}`,
{ itemId, body },
)
return data.create_update.id
}
/**
* Usuarios activos no-guest del workspace (excluye las cuentas bot de Monday,
* cuyo email termina en @agent.monday.com).
*/
async getUsuariosWorkspace(): Promise<UsuarioMonday[]> {
const data = await this.graphql<{
users: { id: string; name: string; email: string; is_guest: boolean; enabled: boolean }[]
}>(`query { users(limit: 50) { id name email is_guest enabled } }`)
return data.users
.filter((u) => !u.is_guest && u.enabled && !u.email.endsWith('@agent.monday.com'))
.map((u) => ({ id: Number(u.id), nombre: u.name, email: u.email }))
}
}
// ---------- 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 DatosOportunidadAfluencia {
nombre: string // name del item = nombre del lead
telefono?: string | undefined
email?: string | undefined
desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo'
}
export interface DatosContacto {
nombre: string
telefono?: string
email?: string
curp?: string
rfc?: string
direccion?: string
estadoLabel: string // p. ej. 'CLIENTE'
// Perfil demográfico (labels del dropdown correspondiente)
sexoLabel?: string | undefined
estadoCivilLabel?: string | undefined
rangoEdadLabel?: string | undefined
actividadEconomicaLabel?: string | undefined
viviendaActualLabel?: string | undefined
propositoCompraLabel?: string | undefined
fechaNacimiento?: string | undefined // 'YYYY-MM-DD'
}
export interface DatosApartado {
clienteNombre: string
unidadCodigo: string
montoApartado?: number
fechaApartado: string // 'YYYY-MM-DD'
fechaLimite: string // 'YYYY-MM-DD'
estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol)
}
export interface DatosVenta {
clienteNombre: string
unidadCodigo?: string
valorVenta?: number
}
export interface DatosMotivoNoCompra {
nombre: string // '<motivo> — <nombre del item de origen>'
categoria: string // una de MOTIVOS_NO_COMPRA
descripcion?: string
}
export interface DatosActividad {
nombre: string
tipoLabel: string // 'Llamada' | 'Reunion' | 'Tarea'
responsableId?: number // user ID de Monday (se omite si no hay responsable claro)
fecha: string // 'YYYY-MM-DD'
estadoLabel: string // 'Abierto' | 'Listo'
leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica)
}
export interface DatosLead {
nombre: string
telefono?: string | undefined
email?: string | undefined
origenLabel?: string | undefined // label real de la columna status 'Origen' del board Leads
desarrolloLabel?: string | undefined // label real de la columna status 'Desarrollo' del board Leads
notas?: string | undefined
// Campos de calificación del interesado (labels reales de las columnas status del board Leads)
modeloLabel?: string | undefined // label de la columna status 'Modelo de Interés' (modelo según desarrollo)
medioContacto?: string | undefined
edad?: number | undefined
estadoCivilLabel?: string | undefined
tipoEmpleoLabel?: string | undefined
ocupacion?: string | undefined
rangoSalarial?: string | undefined
creditoLabel?: string | undefined
tipoEconomiaLabel?: string | undefined
calificaLabel?: string | undefined
loQuierePara?: string | undefined
}
/** Singleton usado por los controllers (los tests instancian el suyo) */
export const mondayService = new MondayService()