import { config } from '../config/index.js' import type { UnidadCotizable } from './cotizacion.service.js' import { AppError } from '../utils/errors.js' /** Unidad tal como la consume el API (y el frontend del cotizador) */ export type Unidad = UnidadCotizable interface ColumnaMeta { id: string title: string type: string } interface ColumnValue { id: string text: string | null value: string | null } interface ItemMonday { id: string name: string column_values: ColumnValue[] } interface ItemConFechas extends ItemMonday { created_at: string updated_at: string } /** Lead del tablero Leads tal como lo consumen las automatizaciones */ export interface LeadMonday { id: string nombre: string createdAt: string // ISO (created_at del item en Monday) updatedAt: string // ISO (updated_at del item en Monday) estado: string // label de la columna status 'Estado' ('' si no tiene) origen: string // label de la columna status 'Origen' ('' si no tiene) telefono: string // columna phone 'Teléfono' ('' si no tiene) email: string // columna email 'Email' ('' si no tiene) vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor' } /** Actividad del tablero Actividades (agenda de seguimiento del CRM) */ export interface ActividadMonday { id: string nombre: string tipo: string // label de la columna status 'Tipo' (Llamada | Reunion | Tarea | ...) fecha: string // 'YYYY-MM-DD' de la columna date 'Fecha' ('' si no tiene) estado: string // label de la columna status 'Estado' (Abierto | Listo | ...) responsableIds: number[] // user IDs de la columna people 'Responsable' } /** Apartado del tablero Apartado (pipeline Enkontrol) */ export interface ApartadoMonday { id: string nombre: string // nombre del cliente unidadCodigo: string // columna text 'Unidad' ('' si no tiene) fechaLimite: string // 'YYYY-MM-DD' de 'Fecha límite' ('' si no tiene) estado: string // label de la columna status 'Estado' } /** Oportunidad del tablero Oportunidades (pipeline comercial) */ export interface OportunidadMonday { id: string nombre: string // nombre del cliente createdAt: string // ISO etapa: string // label de la columna status 'Estado' unidadCodigo: string // columna text 'Unidad' ('' si no tiene) precioFinal: number // columna numbers 'Precio final' (0 si no tiene) } /** Usuario del workspace (para la lista rotativa de vendedores) */ export interface UsuarioMonday { id: number nombre: string email: string } interface GraphQLErrorItem { message: string extensions?: { code?: string } } interface GraphQLResponse { 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 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 { return await this.graphqlOnce(query, variables) } catch (err) { if (!(err instanceof MondayRetryableError)) throw err ultimoError = err } } throw new AppError(502, `Monday no respondió tras ${this.maxReintentos + 1} intentos: ${ultimoError?.message}`) } private async graphqlOnce(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 } return unidades } /** 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': return { phone: String(crudo), countryShortName: 'MX' } case 'checkbox': return { checked: crudo === true || crudo === 'true' ? 'true' : 'false' } case 'people': return { personsAndTeams: [{ id: Number(crudo), kind: 'person' }] } case 'board_relation': return { item_ids: [Number(crudo)] } case 'long_text': return { text: String(crudo) } default: return String(crudo) } } private async construirColumnValues( boardId: string, valores: Record, ): 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, crudo) } return JSON.stringify(out) } private async crearItem(boardId: string, nombre: string, columnValues: string): Promise { const data = await this.graphql<{ create_item: { id: string } }>( `mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) { create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues) { id } }`, { boardId, itemName: nombre, columnValues }, ) return data.create_item.id } // ---------- Escrituras de negocio ---------- async crearOportunidad(datos: DatosOportunidad): Promise { const boardId = config.monday.boards.oportunidades const columnValues = await this.construirColumnValues(boardId, { Unidad: datos.unidadCodigo, 'Cliente email': datos.clienteEmail, 'Cliente teléfono': datos.clienteTelefono, 'Tipo propuesta': datos.tipoPropuestaLabel, 'Precio de lista': datos.precioLista, 'Descuento %': datos.descuentoPct, 'Precio final': datos.precioFinal, 'Fecha cotización': datos.fechaCotizacion, Estado: datos.estadoLabel, }) return this.crearItem(boardId, datos.clienteNombre, columnValues) } /** * Oportunidad en etapa 'Afluencia' a partir de un lead recién capturado * (entrada al funnel; sin unidad ni precios todavía). */ async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise { const boardId = config.monday.boards.oportunidades const columnValues = await this.construirColumnValues(boardId, { 'Cliente teléfono': datos.telefono, 'Cliente email': datos.email, Desarrollo: datos.desarrolloLabel, Estado: 'Afluencia', }) return this.crearItem(boardId, datos.nombre, columnValues) } /** * Actualiza una oportunidad existente con los datos de una cotización * (mismo builder de valores que crearOportunidad) y la mueve a 'Cotizador'. */ async actualizarOportunidadCotizacion(oportunidadId: string, datos: DatosOportunidad): Promise { 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 const columnValues = await this.construirColumnValues(boardId, { Unidad: datos.unidadCodigo, 'Monto apartado': datos.montoApartado, 'Fecha apartado': datos.fechaApartado, 'Fecha límite': datos.fechaLimite, Estado: datos.estadoLabel, }) return this.crearItem(boardId, datos.clienteNombre, columnValues) } async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise { const boardId = config.monday.boards.unidades // Asegura cache fresco para resolver el itemId await this.getUnidades() const itemId = this.unidadesCache?.itemIds.get(codigo) if (!itemId) throw new AppError(404, `Unidad ${codigo} no encontrada en Monday`) const col = await this.columnaId(boardId, 'Disponibilidad') await this.graphql( `mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) { change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id } }`, { boardId, itemId, columnId: col.id, value: JSON.stringify({ label: status }) }, ) this.invalidarCacheUnidades() } // ---------- Escrituras genéricas ---------- /** * Cambia UNA columna de un item, resolviendo el column ID por título y * serializando según el tipo de columna (status, dropdown, checkbox, date...). */ async cambiarValorColumna( boardId: string, itemId: string, tituloColumna: string, valor: string | number | boolean, ): Promise { const col = await this.columnaId(boardId, tituloColumna) await this.graphql( `mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) { change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id } }`, { boardId, itemId, columnId: col.id, value: JSON.stringify(this.valorPorTipo(col, valor)) }, ) } /** Nombre de un item por ID (cualquier tablero) */ async obtenerNombreItem(itemId: string): Promise { 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 = " — ") */ 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, Descripcion: datos.descripcion, }) return this.crearItem(boardId, datos.nombre, columnValues) } /** Actividad de seguimiento ligada a un lead (bitácora del CRM) */ async crearActividad(datos: DatosActividad): Promise { 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)) } /** Un lead del tablero Leads por item ID (el payload del webhook no trae datos) */ async getLeadPorId(itemId: string): Promise { const boardId = config.monday.boards.leads const cols = await this.columnasDe(boardId) const data = await this.graphql<{ items: (ItemConFechas | null)[] }>( `query ($ids: [ID!]) { items(ids: $ids) { id name created_at updated_at column_values { id text value } } }`, { ids: [itemId] }, ) const item = data.items[0] if (!item) return null return this.mapearLead(item, cols) } private mapearLead(item: ItemConFechas, cols: Map): LeadMonday { const colVendedor = cols.get('Vendedor') if (!colVendedor || colVendedor.type !== 'people') { throw new AppError(500, `Columna people "Vendedor" no existe en el tablero Leads`) } const colEstado = cols.get('Estado') const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) const rawVendedor = porId.get(colVendedor.id)?.value let vendedorIds: number[] = [] if (rawVendedor) { try { const parsed = JSON.parse(rawVendedor) as { personsAndTeams?: { id: number; kind: string }[] } vendedorIds = (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id) } catch { vendedorIds = [] } } const colOrigen = cols.get('Origen') const colTelefono = cols.get('Teléfono') const colEmail = cols.get('Email') return { id: item.id, nombre: item.name, createdAt: item.created_at, updatedAt: item.updated_at, estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '', origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '', telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '', email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '', vendedorIds, } } /** * Items del tablero Oportunidades con etapa, unidad y precio final * (los consumen las automatizaciones Aparto/Ganado y los reportes). */ async getOportunidades(): Promise { const boardId = config.monday.boards.oportunidades const cols = await this.columnasDe(boardId) const colEstado = cols.get('Estado') if (!colEstado) throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`) const colUnidad = cols.get('Unidad') const colPrecio = cols.get('Precio final') const items: ItemConFechas[] = [] let cursor: string | null = null do { const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } = cursor === null ? await this.graphql( `query ($ids: [ID!]) { boards(ids: $ids) { items_page(limit: 200) { cursor items { id name created_at updated_at column_values { id text value } } } } }`, { ids: [boardId] }, ) : await this.graphql( `query ($ids: [ID!], $cursor: String!) { boards(ids: $ids) { items_page(limit: 200, cursor: $cursor) { cursor items { id name created_at updated_at column_values { id text value } } } } }`, { ids: [boardId], cursor }, ) const page = data.boards[0]?.items_page if (!page) break items.push(...page.items) cursor = page.cursor } while (cursor !== null) return items.map((item) => { const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) const precio = colPrecio ? Number(porId.get(colPrecio.id)?.text ?? '') : NaN return { id: item.id, nombre: item.name, createdAt: item.created_at, etapa: porId.get(colEstado.id)?.text ?? '', unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '', precioFinal: Number.isFinite(precio) ? precio : 0, } }) } // ---------- Mi día (CRM ligero para vendedores) ---------- /** Parsea el value JSON de una columna people a los user IDs (solo personas) */ private peopleIds(raw: string | null | undefined): number[] { if (!raw) return [] try { const parsed = JSON.parse(raw) as { personsAndTeams?: { id: number; kind: string }[] } return (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id) } catch { return [] } } /** Todas las actividades del tablero Actividades, con tipo, fecha, estado y responsables */ async getActividades(): Promise { const boardId = config.monday.boards.actividades if (boardId === '') return [] const cols = await this.columnasDe(boardId) const colTipo = cols.get('Tipo') const colFecha = cols.get('Fecha') const colEstado = cols.get('Estado') const colResponsable = cols.get('Responsable') const items: ItemMonday[] = [] let cursor: string | null = null do { const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } = cursor === null ? await this.graphql( `query ($ids: [ID!]) { boards(ids: $ids) { items_page(limit: 500) { cursor items { id name column_values { id text value } } } } }`, { ids: [boardId] }, ) : await this.graphql( `query ($ids: [ID!], $cursor: String!) { boards(ids: $ids) { items_page(limit: 500, cursor: $cursor) { cursor items { id name column_values { id text value } } } } }`, { ids: [boardId], cursor }, ) const page = data.boards[0]?.items_page if (!page) break items.push(...page.items) cursor = page.cursor } while (cursor !== null) return items.map((item) => { const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) return { id: item.id, nombre: item.name, tipo: colTipo ? (porId.get(colTipo.id)?.text ?? '') : '', fecha: colFecha ? (porId.get(colFecha.id)?.text ?? '') : '', estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '', responsableIds: colResponsable ? this.peopleIds(porId.get(colResponsable.id)?.value) : [], } }) } /** Todos los apartados del tablero Apartado, con unidad, fecha límite y estado */ async getApartados(): Promise { const boardId = config.monday.boards.apartado const cols = await this.columnasDe(boardId) const colUnidad = cols.get('Unidad') const colFechaLimite = cols.get('Fecha límite') const colEstado = cols.get('Estado') const items: ItemMonday[] = [] let cursor: string | null = null do { const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } = cursor === null ? await this.graphql( `query ($ids: [ID!]) { boards(ids: $ids) { items_page(limit: 500) { cursor items { id name column_values { id text value } } } } }`, { ids: [boardId] }, ) : await this.graphql( `query ($ids: [ID!], $cursor: String!) { boards(ids: $ids) { items_page(limit: 500, cursor: $cursor) { cursor items { id name column_values { id text value } } } } }`, { ids: [boardId], cursor }, ) const page = data.boards[0]?.items_page if (!page) break items.push(...page.items) cursor = page.cursor } while (cursor !== null) return items.map((item) => { const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) return { id: item.id, nombre: item.name, unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '', fechaLimite: colFechaLimite ? (porId.get(colFechaLimite.id)?.text ?? '') : '', estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '', } }) } /** * Labels de una columna status/dropdown (settings_str del board). * Monday los devuelve como objeto {index: label} o como arreglo [{id, name}]. */ async labelsDeColumna(boardId: string, titulo: string): Promise { 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) */ async crearLead(datos: DatosLead): Promise { const boardId = config.monday.boards.leads const columnValues = await this.construirColumnValues(boardId, { Estado: 'NUEVO', Teléfono: datos.telefono, Email: datos.email, Origen: datos.origenLabel, Desarrollo: datos.desarrolloLabel, Notas: datos.notas, }) 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 un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */ async asignarVendedorALead(itemId: string, userId: number): Promise { const boardId = config.monday.boards.leads const col = await this.columnaId(boardId, 'Vendedor') await this.graphql( `mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) { change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id } }`, { boardId, itemId, columnId: col.id, value: JSON.stringify({ personsAndTeams: [{ id: userId, kind: 'person' }] }), }, ) } /** Crea un update (comentario) en un item — usado para los recordatorios de inactividad */ async crearUpdateEnItem(itemId: string, body: string): Promise { 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 })) } } // ---------- DTOs de escritura ---------- export interface DatosOportunidad { clienteNombre: string unidadCodigo: string tipoPropuestaLabel: string precioLista: number descuentoPct: number // porcentaje (2 = 2%) precioFinal: number fechaCotizacion: string // 'YYYY-MM-DD' estadoLabel: string // p. ej. 'COTIZADA' clienteEmail?: string clienteTelefono?: string } export interface DatosOportunidadAfluencia { nombre: string // name del item = nombre del lead telefono?: string | undefined email?: string | undefined desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo' } export interface DatosContacto { nombre: string telefono?: string email?: string curp?: string rfc?: string direccion?: string estadoLabel: string // p. ej. 'CLIENTE' // Perfil demográfico (labels del dropdown correspondiente) sexoLabel?: string | undefined estadoCivilLabel?: string | undefined rangoEdadLabel?: string | undefined actividadEconomicaLabel?: string | undefined viviendaActualLabel?: string | undefined propositoCompraLabel?: string | undefined fechaNacimiento?: string | undefined // 'YYYY-MM-DD' } export interface DatosApartado { clienteNombre: string unidadCodigo: string montoApartado?: number fechaApartado: string // 'YYYY-MM-DD' fechaLimite: string // 'YYYY-MM-DD' estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol) } export interface DatosVenta { clienteNombre: string unidadCodigo?: string valorVenta?: number } export interface DatosMotivoNoCompra { nombre: string // ' — ' categoria: string // una de MOTIVOS_NO_COMPRA descripcion?: string } export interface DatosActividad { nombre: string tipoLabel: string // 'Llamada' | 'Reunion' | 'Tarea' responsableId?: number // user ID de Monday (se omite si no hay responsable claro) fecha: string // 'YYYY-MM-DD' estadoLabel: string // 'Abierto' | 'Listo' leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica) } export interface DatosLead { nombre: string telefono?: string | undefined email?: string | undefined origenLabel?: string | undefined // label real de la columna status 'Origen' del board Leads desarrolloLabel?: string | undefined // label real de la columna status 'Desarrollo' del board Leads notas?: string | undefined } /** Singleton usado por los controllers (los tests instancian el suyo) */ export const mondayService = new MondayService()