Files
FRASA-CAS/api-gateway/src/services/monday.service.ts
Consult AS Technology 60649890eb feat(modo-local): fallback completo sin Monday + cola de sincronización
- Circuit breaker saludMonday (3 fallos → modo local; ping cada 60s)
- Endpoint GET /api/sistema/estado {mondayOk, pendientesSync}
- LeadCaptura extendida (board, etapa, origen, motivo, pendienteSync)
- Tablas Unidad (espejo inventario, 288 seed) + SyncPendiente
- crmLocal: crearLead, afluencia, cambio etapa, descarte, conversión, apartar
- Ruteo en controllers: fallback local ante 502 o Monday caído
- Mi día local: resumen completo desde SQLite + unión con Monday
- Fallbacks: unidades, contactos, reportes, prefill, buscador
- Sync worker cada 2min + handlers 15 tipos (con DependenciaPendienteError)
- Gate automatizaciones: skip si !mondayOk
- Banner 'Modo local' en 5 apps + badge pendiente en mi-dia
- 597 API tests + 263 front tests verdes

Env MODO_LOCAL_FORZADO=true para testing sin Monday.
2026-09-06 08:08:56 +00:00

1748 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
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
}
/** Item de cualquier tablero con el grupo donde está (automatización estado → grupo) */
export interface ItemConGrupo {
id: string
name: string
groupId: string
column_values: ColumnValue[]
}
/** 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'
// Perfilamiento (alimenta las gráficas de /dash/); todos tolerantes: si la
// columna no existe en el board o viene vacía, quedan '' / null
desarrollo?: string // status 'Desarrollo'
modeloInteres?: string // status 'Modelo de Interés'
medioContacto?: string // text 'Medio de Contacto'
edad?: number | null // numbers 'Edad' (null si vacía o no numérica)
estadoCivil?: string // status 'Estado Civil'
tipoEmpleo?: string // status 'Tipo de Empleo'
credito?: string // status 'Crédito'
tipoEconomia?: string // status 'Tipo de Economía'
califica?: string // status 'Califica'
rangoSalarial?: string // text 'Rango Salarial'
loQuierePara?: string // text 'Lo Quiere Para'
}
/** 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'
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
telefono?: string // columna phone 'Teléfono' ('' si no tiene)
email?: string // columna email 'Correo electrónico' ('' si no tiene)
precioFinal?: number // columna numbers 'Precio Final' (0 si no tiene)
fechaCompra?: string // 'YYYY-MM-DD' de 'Fecha Compra' ('' si no tiene)
curp?: string // columna text 'curp' ('' si no tiene)
formaPago?: string // dropdown 'Forma de Pago' ('' si no tiene)
enganche?: number // columna numbers 'Enganche' (0 si no tiene)
montoApartado?: number // columna numbers 'Monto apartado' (0 si no tiene)
}
/** Contacto del tablero Contactos (módulo Contactos de la landing) */
export interface ContactoMonday {
id: string
nombre: string
telefono: string // columna phone 'Teléfono' ('' si no tiene)
email: string // columna email 'Email' ('' si no tiene)
estado: string // label de la columna status 'Estado' ('' si nunca se escribió)
updatedAt: string // ISO (updated_at del item en Monday)
}
/** Oportunidad del tablero Oportunidades (pipeline comercial) */
export interface OportunidadMonday {
id: string
nombre: string // nombre del cliente
createdAt: string // ISO
updatedAt: string // ISO (updated_at del item en Monday)
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)
asesorIds: number[] // user IDs asignados en la columna people 'Asesor'
telefono: string // columna phone 'Cliente teléfono' ('' si no tiene)
email: string // columna email 'Cliente email' ('' si no tiene)
origen: string // dropdown 'Origen del Lead' ('' si no tiene)
// Perfilamiento de la afluencia (alimenta las gráficas de /dash/); todos
// tolerantes: si la columna no existe en el board o viene vacía, quedan '' / null
desarrollo?: string // status 'Desarrollo'
modeloInteres?: string // status 'Modelo de Interés'
medioContacto?: string // text 'Medio de Contacto'
edad?: number | null // numbers 'Edad' (null si vacía o no numérica)
estadoCivil?: string // dropdown 'Estado Civil'
tipoEmpleo?: string // status 'Tipo de Empleo'
credito?: string // status 'Crédito'
tipoEconomia?: string // status 'Tipo de Econimia' (SIC: el título tiene typo en Monday)
califica?: string // dropdown 'Lead Califica'
rangoSalarial?: string // text 'Rango Salarial'
loQuierePara?: string // dropdown 'Proposito de Compra'
}
/** Usuario del workspace (para la lista rotativa de vendedores) */
export interface UsuarioMonday {
id: number
nombre: string
email: string
}
/** Entrada del activity log de Monday (boards { activity_logs }) */
interface ActivityLogMonday {
id: string
event: string
created_at: string // string numérico, normalmente en microsegundos
user_id: string
data: string // JSON string; pulse_id identifica el item
}
/** Evento del activity log de un item (historial completo, no solo el último) */
export interface EventoHistorialMonday {
itemId: string
fechaISO: string
usuarioId: number | null // user_id de Monday; null si no es numérico (automatizaciones)
evento: string // event crudo (create_pulse, update_column_value, ...)
}
/** Último evento del activity log de un item (para "Último cambio" de Mi día) */
export interface UltimoCambioMonday {
fechaISO: string
usuarioId: number | null // user_id de Monday; null si no es numérico
evento: string // event crudo del activity log (create_pulse, update_status, ...)
}
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
/**
* Grupo del tablero Motivos No Compra por categoría (ids verificados sep-2026).
* El descarte crea el motivo directo en su grupo; si la categoría no está
* mapeada, el item cae en el grupo default del tablero.
*/
export const GRUPOS_MOTIVOS_NO_COMPRA: Record<string, string> = {
Timing: 'group_mm6spcsj',
Cliente: 'group_mm6sp7nr',
Competencia: 'group_mm6sbdfy',
Financiamiento: 'group_mm6ssymc',
Ubicacion: 'group_mm6sqern', // sin acento: así está el grupo en Monday
Producto: 'group_mm6s7j0b',
Precio: 'group_mm6sze0z',
}
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 {
const data = await this.graphqlOnce<T>(query, variables)
saludMonday.registrarExito()
return data
} catch (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}`)
}
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 }
// 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<void> {
// 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<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': {
// LADA explícita: '+1...' (o 11 dígitos empezando en 1) = USA; el resto = México.
// Monday rechaza el número con espacios ("+52 664..."): se envía compacto.
const tel = String(crudo).replace(/\s+/g, '')
const digitos = tel.replace(/\D/g, '')
const esUsa = tel.startsWith('+1') || (digitos.length === 11 && digitos.startsWith('1'))
return { phone: tel, countryShortName: esUsa ? 'US' : '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)
}
}
/**
* Resuelve el label real de una columna status/dropdown tolerando mayúsculas
* y minúsculas (el código escribe 'NUEVO' aunque el board diga 'Nuevo' tras
* un rename en la UI; los dropdown NO auto-crean labels al escribir). Sin
* match devuelve el valor tal cual (Monday dirá que el label no existe).
*/
private async resolverLabel(
boardId: string,
col: ColumnaMeta,
valor: string | number | boolean,
): Promise<string | number | boolean> {
if (col.type !== 'status' && col.type !== 'dropdown') return valor
const buscado = String(valor).trim().toUpperCase()
const labels = await this.labelsDeColumna(boardId, col.title)
return labels.find((l) => l.trim().toUpperCase() === buscado) ?? valor
}
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, await this.resolverLabel(boardId, col, crudo))
}
return JSON.stringify(out)
}
private async crearItem(boardId: string, nombre: string, columnValues: string, groupId?: string): Promise<string> {
const data = await this.graphql<{ create_item: { id: string } }>(
`mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!, $groupId: String) {
create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues, group_id: $groupId) { id }
}`,
{ boardId, itemName: nombre, columnValues, groupId: groupId ?? null },
)
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' con todo el perfil de captura (la
* afluencia ES la oportunidad: no hay item aparte en el tablero Leads).
* La columna date 'Fecha' lleva la fecha de captura (hoy, hora local).
* estadoLabel permite crearla en otra etapa (p. ej. 'Agendo Cita' al
* convertir un lead del tablero Leads).
*/
async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise<string> {
const boardId = config.monday.boards.oportunidades
const hoy = new Date()
const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}`
const columnValues = await this.construirColumnValues(boardId, {
Estado: datos.estadoLabel ?? 'Afluencia',
Fecha: fechaHoy,
'Cliente teléfono': datos.telefono,
'Cliente email': datos.email,
Desarrollo: datos.desarrolloLabel,
'Modelo de Interés': datos.modeloLabel,
'Origen del Lead': datos.origenLabel,
'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 Econimia': datos.tipoEconomiaLabel, // SIC: typo en el título real de la columna
'Lead Califica': datos.calificaLabel,
'Proposito de Compra': datos.loQuierePara,
COMENTARIOS: datos.notas,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
/**
* Actualiza una oportunidad existente con el perfil completo de la afluencia
* (mismo builder que crearOportunidadAfluencia) y la pone en etapa
* 'Afluencia'. La usa el botón "Afluencia" de Mis oportunidades en Mi día:
* el formulario se manda prellenado y el envío enriquece el item en vez de
* duplicarlo. El nombre del item se actualiza por el column id 'name'.
*/
async actualizarOportunidadAfluencia(oportunidadId: string, datos: DatosOportunidadAfluencia): Promise<void> {
const boardId = config.monday.boards.oportunidades
const hoy = new Date()
const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}`
const valores = JSON.parse(
await this.construirColumnValues(boardId, {
Estado: 'Afluencia',
Fecha: fechaHoy,
'Cliente teléfono': datos.telefono,
'Cliente email': datos.email,
Desarrollo: datos.desarrolloLabel,
'Modelo de Interés': datos.modeloLabel,
'Origen del Lead': datos.origenLabel,
'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 Econimia': datos.tipoEconomiaLabel, // SIC: typo en el título real
'Lead Califica': datos.calificaLabel,
'Proposito de Compra': datos.loQuierePara,
COMENTARIOS: datos.notas,
}),
) as Record<string, unknown>
if (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: oportunidadId, columnValues: JSON.stringify(valores) },
)
}
/**
* 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
// Mapeo a las columnas reales del tablero Apartado (verificadas en Monday):
// contacto (Teléfono/Correo/curp), operación (Precio Final/Enganche/
// Fecha Compra/Forma de Pago), asignación (Vendedor) y Observaciones.
// Todo lo opcional se omite si viene undefined (mismo patrón de siempre).
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,
Teléfono: datos.telefono,
'Correo electrónico': datos.email,
curp: datos.curp, // el título de la columna es minúscula en el board
'Precio Final': datos.precioFinal,
Enganche: datos.enganche,
'Fecha Compra': datos.fechaCompra,
Vendedor: datos.vendedorId,
'Forma de Pago': datos.formaPagoLabel, // dropdown; el resolver ajusta case
Observaciones: datos.observaciones,
})
return this.crearItem(boardId, datos.clienteNombre, columnValues)
}
/**
* Actualiza un apartado existente con los campos presentes (mismo builder de
* valores que crearApartado) — usado cuando el alta de cliente arranca desde
* un apartado ya creado (botón "Firma Cliente (Venta)" de Mis apartados):
* se enriquece el item en vez de crear uno duplicado.
*/
async actualizarApartado(itemId: string, datos: Partial<DatosApartado>): Promise<void> {
const boardId = config.monday.boards.apartado
const valores = JSON.parse(
await this.construirColumnValues(boardId, {
Unidad: datos.unidadCodigo,
'Monto apartado': datos.montoApartado,
'Fecha apartado': datos.fechaApartado,
'Fecha límite': datos.fechaLimite,
Estado: datos.estadoLabel,
Teléfono: datos.telefono,
'Correo electrónico': datos.email,
curp: datos.curp,
'Precio Final': datos.precioFinal,
Enganche: datos.enganche,
'Fecha Compra': datos.fechaCompra,
Vendedor: datos.vendedorId,
'Forma de Pago': datos.formaPagoLabel,
Observaciones: datos.observaciones,
}),
) as Record<string, unknown>
if (datos.clienteNombre !== undefined && datos.clienteNombre !== '') valores['name'] = datos.clienteNombre
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 actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise<void> {
// 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<void> {
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 ----------
/**
* 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)
const resuelto = await this.resolverLabel(boardId, col, valor)
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, resuelto)) },
)
}
/** ID de una columna por título exacto; null si no existe (lectura tolerante) */
async columnaIdPorTitulo(boardId: string, titulo: string): Promise<string | null> {
const cols = await this.columnasDe(boardId)
return cols.get(titulo)?.id ?? null
}
/** Item de cualquier tablero con su grupo y column_values; null si no existe */
async getItemConGrupo(itemId: string): Promise<ItemConGrupo | null> {
const data = await this.graphql<{ items: (ItemMonday & { group: { id: string } } | null)[] }>(
`query ($ids: [ID!]) {
items(ids: $ids) { id name group { id } column_values { id text value } }
}`,
{ ids: [itemId] },
)
const item = data.items[0]
if (!item) return null
return { id: item.id, name: item.name, groupId: item.group.id, column_values: item.column_values }
}
/** Mueve un item a otro grupo de su mismo tablero */
async moverItemAGrupo(itemId: string, groupId: string): Promise<void> {
await this.graphql<{ move_item_to_group: { id: string } }>(
`mutation ($itemId: ID!, $groupId: String!) {
move_item_to_group(item_id: $itemId, group_id: $groupId) { id }
}`,
{ itemId, groupId },
)
}
/** 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>"),
* creado directo en el grupo de su categoría (si está mapeada) */
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,
Subcategoria: datos.subcategoria,
Descripcion: datos.descripcion,
})
const buscada = datos.categoria.trim().toUpperCase()
const groupId = Object.entries(GRUPOS_MOTIVOS_NO_COMPRA).find(
([categoria]) => categoria.toUpperCase() === buscada,
)?.[1]
return this.crearItem(boardId, datos.nombre, columnValues, groupId)
}
/** 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))
}
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')
// Cuando el status nunca se ha escrito, Monday devuelve value=null pero
// `text` muestra el label default del tablero (hoy "Descartado"): el estado
// real es vacío, no el default. Se lee `text` SOLO si hay value.
const estadoCv = colEstado ? porId.get(colEstado.id) : undefined
const estado = estadoCv?.value ? (estadoCv.text ?? '') : ''
// Perfilamiento: lectura tolerante por título ('' / null si falta la columna)
const textoDe = (titulo: string): string => {
const col = cols.get(titulo)
return col ? (porId.get(col.id)?.text ?? '') : ''
}
const edadRaw = textoDe('Edad')
const edadNum = Number(edadRaw)
return {
id: item.id,
nombre: item.name,
createdAt: item.created_at,
updatedAt: item.updated_at,
estado,
origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '',
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
vendedorIds,
desarrollo: textoDe('Desarrollo'),
modeloInteres: textoDe('Modelo de Interés'),
medioContacto: textoDe('Medio de Contacto'),
edad: edadRaw.trim() === '' || Number.isNaN(edadNum) ? null : edadNum,
estadoCivil: textoDe('Estado Civil'),
tipoEmpleo: textoDe('Tipo de Empleo'),
credito: textoDe('Crédito'),
tipoEconomia: textoDe('Tipo de Economía'),
califica: textoDe('Califica'),
rangoSalarial: textoDe('Rango Salarial'),
loQuierePara: textoDe('Lo Quiere Para'),
}
}
/**
* Items del tablero Oportunidades con etapa, unidad, precio final, asesor
* asignado y perfil de afluencia (los consumen las automatizaciones
* Aparto/Ganado, el round-robin, Mi día y los reportes).
*/
async getOportunidades(): Promise<OportunidadMonday[]> {
const boardId = config.monday.boards.oportunidades
const cols = await this.columnasDe(boardId)
if (!cols.has('Estado')) {
throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${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.mapearOportunidad(item, cols))
}
private mapearOportunidad(item: ItemConFechas, cols: Map<string, ColumnaMeta>): OportunidadMonday {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
// Lectura tolerante por título ('' si falta la columna o el valor)
const textoDe = (titulo: string): string => {
const col = cols.get(titulo)
return col ? (porId.get(col.id)?.text ?? '') : ''
}
// Misma corrección que mapearLead: cuando el status nunca se ha escrito,
// Monday devuelve value=null pero `text` muestra el default del tablero;
// la etapa real es vacía. Se lee `text` SOLO si hay value.
const colEstado = cols.get('Estado')
const estadoCv = colEstado ? porId.get(colEstado.id) : undefined
const etapa = estadoCv?.value ? (estadoCv.text ?? '') : ''
const colAsesor = cols.get('Asesor')
const precio = Number(textoDe('Precio final'))
const edadRaw = textoDe('Edad')
const edadNum = Number(edadRaw)
return {
id: item.id,
nombre: item.name,
createdAt: item.created_at,
updatedAt: item.updated_at,
etapa,
unidadCodigo: textoDe('Unidad'),
precioFinal: Number.isFinite(precio) ? precio : 0,
asesorIds: colAsesor ? this.peopleIds(porId.get(colAsesor.id)?.value) : [],
telefono: textoDe('Cliente teléfono'),
email: textoDe('Cliente email'),
origen: textoDe('Origen del Lead'),
desarrollo: textoDe('Desarrollo'),
modeloInteres: textoDe('Modelo de Interés'),
medioContacto: textoDe('Medio de Contacto'),
edad: edadRaw.trim() === '' || Number.isNaN(edadNum) ? null : edadNum,
estadoCivil: textoDe('Estado Civil'),
tipoEmpleo: textoDe('Tipo de Empleo'),
credito: textoDe('Crédito'),
tipoEconomia: textoDe('Tipo de Econimia'), // SIC: typo en el título real
califica: textoDe('Lead Califica'),
rangoSalarial: textoDe('Rango Salarial'),
loQuierePara: textoDe('Proposito de Compra'),
}
}
/**
* Oportunidad con el mismo teléfono (dedup de la captura de afluencia, que
* ahora vive en Oportunidades). Compara solo dígitos y los últimos 10
* (tolerante a lada/prefijo 52). null si no hay duplicado.
*/
async buscarOportunidadPorTelefono(telefono: string): Promise<OportunidadMonday | null> {
const digitos = telefono.replace(/\D/g, '')
if (digitos.length < 10) return null
const clave = digitos.slice(-10)
const oportunidades = await this.getOportunidades()
return (
oportunidades.find((o) => {
const d = o.telefono.replace(/\D/g, '')
return d.length >= 10 && d.slice(-10) === clave
}) ?? null
)
}
// ---------- Mi día (CRM ligero para vendedores) ----------
/**
* Último evento del activity log por item: UNA query por tablero con todos
* los itemIds (limit = items × 5, por si hay varios eventos por item). El
* item se identifica por el pulse_id del payload `data` (JSON string). El
* created_at viene como string numérico; por magnitud se normaliza a ms:
* > 1e16 ⇒ centésimas de µs (así llega hoy, 17 dígitos), > 1e14 ⇒ µs,
* si no ms. itemIds vacío ⇒ Map vacío sin llamar a Monday.
*/
async getUltimosCambios(boardId: string, itemIds: string[]): Promise<Map<string, UltimoCambioMonday>> {
const resultado = new Map<string, UltimoCambioMonday>()
if (itemIds.length === 0) return resultado
const data = await this.graphql<{ boards: { activity_logs: ActivityLogMonday[] }[] }>(
`query ($ids: [ID!], $itemIds: [ID!], $limit: Int!) {
boards(ids: $ids) {
activity_logs(item_ids: $itemIds, limit: $limit) { id event created_at user_id data }
}
}`,
{ ids: [boardId], itemIds, limit: itemIds.length * 5 },
)
for (const log of data.boards[0]?.activity_logs ?? []) {
let itemId: string | undefined
try {
const datos = JSON.parse(log.data) as { pulse_id?: number | string }
itemId = datos.pulse_id === undefined ? undefined : String(datos.pulse_id)
} catch {
continue
}
if (itemId === undefined) continue
const crudo = Number(log.created_at)
if (!Number.isFinite(crudo)) continue
// Normaliza a ms por magnitud: 100ns (17 dígitos) /1e4, µs /1e3, ms directo
const ms = crudo > 1e16 ? crudo / 1e4 : crudo > 1e14 ? crudo / 1e3 : crudo
const fechaISO = new Date(ms).toISOString()
// Las fechas ISO comparan bien como strings: solo nos quedamos con el más reciente
const actual = resultado.get(itemId)
if (actual && actual.fechaISO >= fechaISO) continue
const userId = Number(log.user_id)
resultado.set(itemId, {
fechaISO,
usuarioId: Number.isFinite(userId) && userId > 0 ? userId : null,
evento: log.event,
})
}
return resultado
}
/**
* TODOS los eventos del activity log de un conjunto de items (historial del
* módulo Contactos), más reciente primero. Misma normalización de created_at
* que getUltimosCambios.
*/
async getHistorialItems(boardId: string, itemIds: string[]): Promise<EventoHistorialMonday[]> {
if (itemIds.length === 0) return []
const data = await this.graphql<{ boards: { activity_logs: ActivityLogMonday[] }[] }>(
`query ($ids: [ID!], $itemIds: [ID!], $limit: Int!) {
boards(ids: $ids) {
activity_logs(item_ids: $itemIds, limit: $limit) { id event created_at user_id data }
}
}`,
{ ids: [boardId], itemIds, limit: Math.min(itemIds.length * 10, 200) },
)
const eventos: EventoHistorialMonday[] = []
for (const log of data.boards[0]?.activity_logs ?? []) {
let itemId: string | undefined
try {
const datos = JSON.parse(log.data) as { pulse_id?: number | string }
itemId = datos.pulse_id === undefined ? undefined : String(datos.pulse_id)
} catch {
continue
}
if (itemId === undefined) continue
const crudo = Number(log.created_at)
if (!Number.isFinite(crudo)) continue
const ms = crudo > 1e16 ? crudo / 1e4 : crudo > 1e14 ? crudo / 1e3 : crudo
const userId = Number(log.user_id)
eventos.push({
itemId,
fechaISO: new Date(ms).toISOString(),
usuarioId: Number.isFinite(userId) && userId > 0 ? userId : null,
evento: log.event,
})
}
return eventos.sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
}
/** Item del tablero Contactos con TODAS sus columnas por título ('' si vacía) */
async getContactoDetalle(itemId: string): Promise<Record<string, string> | null> {
const boardId = config.monday.boards.contactos
const [cols, item] = await Promise.all([this.columnasDe(boardId), this.getItemConGrupo(itemId)])
if (!item) return null
const porTitulo: Record<string, string> = { nombre: item.name }
for (const [titulo, col] of cols) {
const cv = item.column_values.find((c) => c.id === col.id)
// status nunca escrito: value null pero text muestra el default del tablero
porTitulo[titulo] = cv?.value ? (cv.text ?? '') : ''
}
return porTitulo
}
/** 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, estado y vendedor */
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 colVendedor = cols.get('Vendedor')
const colTelefono = cols.get('Teléfono')
const colEmail = cols.get('Correo electrónico')
const colPrecioFinal = cols.get('Precio Final')
const colFechaCompra = cols.get('Fecha Compra')
const colCurp = cols.get('curp')
const colFormaPago = cols.get('Forma de Pago')
const colEnganche = cols.get('Enganche')
const colMontoApartado = cols.get('Monto apartado')
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]))
// Misma corrección que mapearLead: status nunca escrito → value null y
// `text` muestra el default del tablero; el estado real es vacío
const estadoCv = colEstado ? porId.get(colEstado.id) : undefined
const estado = estadoCv?.value ? (estadoCv.text ?? '') : ''
return {
id: item.id,
nombre: item.name,
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
fechaLimite: colFechaLimite ? (porId.get(colFechaLimite.id)?.text ?? '') : '',
estado,
vendedorIds: colVendedor ? this.peopleIds(porId.get(colVendedor.id)?.value) : [],
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
precioFinal: colPrecioFinal ? Number(porId.get(colPrecioFinal.id)?.text ?? 0) || 0 : 0,
fechaCompra: colFechaCompra ? (porId.get(colFechaCompra.id)?.text ?? '') : '',
curp: colCurp ? (porId.get(colCurp.id)?.text ?? '') : '',
formaPago: colFormaPago ? (porId.get(colFormaPago.id)?.text ?? '') : '',
enganche: colEnganche ? Number(porId.get(colEnganche.id)?.text ?? 0) || 0 : 0,
montoApartado: colMontoApartado ? Number(porId.get(colMontoApartado.id)?.text ?? 0) || 0 : 0,
}
})
}
/**
* Contactos del tablero Contactos (módulo Contactos de la landing): nombre,
* teléfono, email y estado, ordenados por updated_at desc, máx 100.
*/
async getContactos(): Promise<ContactoMonday[]> {
const boardId = config.monday.boards.contactos
const cols = await this.columnasDe(boardId)
const colTelefono = cols.get('Teléfono')
const colEmail = cols.get('Email')
const colEstado = cols.get('Estado')
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: 100) {
cursor
items { id name updated_at column_values { id text value } }
}
}
}`,
{ ids: [boardId] },
)
: await this.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) {
items_page(limit: 100, cursor: $cursor) {
cursor
items { id name 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]))
// Misma corrección que mapearLead: status nunca escrito → value null y
// `text` muestra el default del tablero; el estado real es vacío
const estadoCv = colEstado ? porId.get(colEstado.id) : undefined
const estado = estadoCv?.value ? (estadoCv.text ?? '') : ''
return {
id: item.id,
nombre: item.name,
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
estado,
updatedAt: item.updated_at,
}
})
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, 100)
}
/**
* 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 desde
* /hoy o captura manual). La columna date 'Fecha' lleva la fecha de captura
* (hoy, hora local). Los leads NO crean oportunidad (eso es /api/afluencias).
*/
async crearLead(datos: DatosLead): Promise<string> {
const boardId = config.monday.boards.leads
const hoy = new Date()
const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}`
const columnValues = await this.construirColumnValues(boardId, {
Estado: 'NUEVO',
Fecha: fechaHoy,
Teléfono: datos.telefono,
Email: datos.email,
Origen: datos.origenLabel,
Desarrollo: datos.desarrolloLabel,
Notas: datos.notas,
})
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 una persona (user ID de Monday) a una columna people de un item */
private async asignarPersona(boardId: string, itemId: string, tituloColumna: string, userId: number): 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({ personsAndTeams: [{ id: userId, kind: 'person' }] }),
},
)
}
/** Asigna un asesor (user ID de Monday) a la columna people "Asesor" de una oportunidad */
async asignarAsesorAOportunidad(itemId: string, userId: number): Promise<void> {
return this.asignarPersona(config.monday.boards.oportunidades, itemId, 'Asesor', userId)
}
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
return this.asignarPersona(config.monday.boards.leads, itemId, 'Vendedor', userId)
}
/** 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 }))
}
/**
* Miembros de un equipo (team) de Monday por nombre, case-insensitive
* (p. ej. "vendedores" para el round-robin). Mismo filtro que el workspace.
*/
async getUsuariosEquipo(nombre: string): Promise<UsuarioMonday[]> {
const data = await this.graphql<{
teams: { name: string; users: { id: string; name: string; email: string; is_guest: boolean; enabled: boolean }[] }[] | null
}>(`query { teams { name users { id name email is_guest enabled } } }`)
const equipo = (data.teams ?? []).find(
(t) => t.name.trim().toUpperCase() === nombre.trim().toUpperCase(),
)
if (!equipo) throw new AppError(500, `El equipo "${nombre}" no existe en Monday`)
return equipo.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 interesado
/** Etapa inicial (default 'Afluencia'; 'Agendo Cita' al convertir un lead) */
estadoLabel?: string | undefined
telefono?: string | undefined
email?: string | undefined
desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo'
// Campos de calificación de la afluencia (labels reales de las columnas del
// board Oportunidades; los dropdown no auto-crean labels, deben venir sembrados)
origenLabel?: string | undefined // dropdown 'Origen del Lead'
modeloLabel?: string | undefined // status 'Modelo de Interés'
medioContacto?: string | undefined // text 'Medio de Contacto'
edad?: number | undefined
estadoCivilLabel?: string | undefined // dropdown 'Estado Civil'
tipoEmpleoLabel?: string | undefined // status 'Tipo de Empleo'
ocupacion?: string | undefined // text 'Ocupación'
rangoSalarial?: string | undefined // text 'Rango Salarial'
creditoLabel?: string | undefined // status 'Crédito'
tipoEconomiaLabel?: string | undefined // status 'Tipo de Econimia' (SIC: typo real)
calificaLabel?: string | undefined // dropdown 'Lead Califica'
loQuierePara?: string | undefined // dropdown 'Proposito de Compra'
notas?: string | undefined // long_text 'COMENTARIOS'
}
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)
// Contacto y operación (opcionales; columnas reales del tablero Apartado)
telefono?: string | undefined // phone 'Teléfono'
email?: string | undefined // email 'Correo electrónico'
curp?: string | undefined // text 'curp' (título minúscula en el board)
precioFinal?: number | undefined // numbers 'Precio Final'
enganche?: number | undefined // numbers 'Enganche'
fechaCompra?: string | undefined // date 'Fecha Compra' ('YYYY-MM-DD')
vendedorId?: number | undefined // people 'Vendedor' (user ID de Monday)
formaPagoLabel?: string | undefined // dropdown 'Forma de Pago' (labels ya sembrados)
observaciones?: string | undefined // long_text 'Observaciones'
}
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
/** Label de la columna status 'Subcategoria' (catálogo del board de referencia) */
subcategoria?: string
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)
}
/** Captura ligera de lead (tablero Leads): la forma corta de /hoy y Monday UI */
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
}
/** Singleton usado por los controllers (los tests instancian el suyo) */
export const mondayService = new MondayService()