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 { 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 => 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 = { 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>() // Cache de unidades + mapa codigo -> itemId (para escrituras) private unidadesCache: { unidades: Unidad[]; itemIds: Map; 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(query: string, variables: Record = {}): Promise { 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(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(query: string, variables: Record): Promise { 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 // 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> { 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(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 { 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 { 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 { 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() 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 { // 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 { 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 { await this.getUnidades() // asegura cache fresco return this.unidadesCache?.itemIds.get(codigo) ?? null } private mapearUnidad(item: ItemMonday, cols: Map): 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 { 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, ): Promise { const out: Record = {} 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 { 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 { 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 { 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 { 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 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 { 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 { 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): Promise { 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 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 { 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): Promise { 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 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 { // 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 { 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 { 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 { 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 { 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 { 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 { 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 = " — "), * creado directo en el grupo de su categoría (si está mapeada) */ async crearMotivoNoCompra(datos: DatosMotivoNoCompra): Promise { 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 { 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 { 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): 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 { 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): 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 { 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> { const resultado = new Map() 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 { 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 | 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 = { 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 { 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 { 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 { 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 { 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 | { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 // ' — ' 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()