feat: roles con modulos por rol + mejoras Mi dia y descarte

- Matriz de roles (vendedor, administracion_venta, director_ventas,
  escrituracion, direccion_general, admin) con modulos visibles por rol;
  auth/me y login incluyen 'modulos'; PATCH /usuarios/:id/rol (admin).
- Landing: Cobranza -> Clientes, Chat IA -> Chat Hermes, nuevo modulo
  Contactos (vista interna leyendo el tablero Contactos), grid filtrado
  por rol, rol visible en Configuracion de cuenta.
- Dashboards: tab 'Tablero comercial' y tabs filtrados por rol.
- Boton '<- Menu' en el header de todas las apps.
- Mi dia: cada lead muestra el ultimo cambio (que, quien, cuando) desde
  el activity log de Monday (normalizacion de created_at 100ns/us/ms).
- Descarte: el motivo se crea en el grupo de su categoria en el tablero
  Motivos No Compra.
This commit is contained in:
2026-09-04 09:12:01 +00:00
parent 42cfe937cf
commit f72354f775
33 changed files with 1224 additions and 114 deletions

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { MODULOS, esRolValido, modulosDeRol } from './roles.js'
const ESPERADOS: Record<string, string[]> = {
vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
administracion_venta: [
'mi-dia',
'alta-cliente',
'alta-afluencia',
'cotizador',
'mi-tablero',
'contactos',
'chat',
'cobranza',
],
director_ventas: [
'mi-dia',
'alta-cliente',
'alta-afluencia',
'cotizador',
'mi-tablero',
'contactos',
'chat',
'cobranza',
'tablero-comercial',
],
escrituracion: [
'mi-dia',
'alta-cliente',
'alta-afluencia',
'cotizador',
'mi-tablero',
'contactos',
'chat',
'cobranza',
],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat'],
admin: [...MODULOS],
}
describe('modulosDeRol', () => {
it.each(Object.entries(ESPERADOS))('rol %s devuelve exactamente su lista', (rol, esperados) => {
expect(modulosDeRol(rol)).toEqual(esperados)
})
it('roles legacy (coordinador, corporativo) se tratan como director_ventas', () => {
expect(modulosDeRol('coordinador')).toEqual(ESPERADOS['director_ventas'])
expect(modulosDeRol('corporativo')).toEqual(ESPERADOS['director_ventas'])
})
it('rol desconocido cae al fallback seguro (vendedor)', () => {
expect(modulosDeRol('otro')).toEqual(ESPERADOS['vendedor'])
expect(modulosDeRol('')).toEqual(ESPERADOS['vendedor'])
})
it('tolera mayúsculas y espacios', () => {
expect(modulosDeRol(' Admin ')).toEqual([...MODULOS])
})
})
describe('esRolValido', () => {
it('acepta solo los roles de la matriz nueva', () => {
for (const rol of Object.keys(ESPERADOS)) expect(esRolValido(rol)).toBe(true)
expect(esRolValido('coordinador')).toBe(false)
expect(esRolValido('vendedorx')).toBe(false)
})
})

View File

@@ -0,0 +1,69 @@
// Matriz de módulos visibles por rol (definida con el cliente, sep-2026).
// Es la fuente de verdad del ecosistema: GET /auth/me la expone como
// `modulos` y las apps filtran su navegación con ella.
export const MODULOS = [
'mi-dia',
'alta-cliente',
'alta-afluencia',
'cotizador',
'mi-tablero',
'tablero-comercial',
'cobranza',
'contactos',
'chat',
] as const
export type Modulo = (typeof MODULOS)[number]
export const ROLES = [
'vendedor',
'administracion_venta',
'director_ventas',
'escrituracion',
'direccion_general',
'admin',
] as const
export type Rol = (typeof ROLES)[number]
const MODULOS_VENDEDOR: readonly Modulo[] = [
'mi-dia',
'alta-cliente',
'alta-afluencia',
'cotizador',
'mi-tablero',
'contactos',
'chat',
]
const MATRIZ: Record<Rol, readonly Modulo[]> = {
vendedor: MODULOS_VENDEDOR,
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat'],
admin: MODULOS,
}
// Roles que ya existen en la BD pero no en la matriz nueva: se homologan a
// director_ventas (su equivalente funcional en la operación actual).
const ROLES_LEGACY: Record<string, Rol> = {
coordinador: 'director_ventas',
corporativo: 'director_ventas',
}
/**
* Módulos visibles para un rol. Roles legacy ('coordinador', 'corporativo') se
* tratan como director_ventas; un rol desconocido cae a vendedor (fallback
* seguro: es el conjunto más chico de módulos operativos).
*/
export function modulosDeRol(rol: string): Modulo[] {
const clave = rol.trim().toLowerCase()
const rolEfectivo: Rol =
(ROLES as readonly string[]).includes(clave) ? (clave as Rol) : (ROLES_LEGACY[clave] ?? 'vendedor')
return [...MATRIZ[rolEfectivo]]
}
/** ¿Es un rol válido de la matriz nueva? (para PATCH /usuarios/:id/rol) */
export function esRolValido(rol: string): boolean {
return (ROLES as readonly string[]).includes(rol)
}

View File

@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { listarContactosEndpoint } from './contactos.controller.js'
// ---------- Monday mockeado (los tests no tocan la red) ----------
const mondayMock = vi.hoisted(() => ({ getContactos: vi.fn() }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
const CONTACTOS = [
{
id: '1',
nombre: 'JUAN PÉREZ',
telefono: '5512345678',
email: 'juan@correo.mx',
estado: 'CLIENTE',
updatedAt: '2026-09-01T10:00:00Z',
},
]
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
beforeEach(() => {
vi.clearAllMocks()
mondayMock.getContactos.mockResolvedValue(CONTACTOS)
})
describe('listarContactosEndpoint (GET /api/contactos)', () => {
it('devuelve los contactos del tablero de Monday', async () => {
const { res, json } = resFake()
await listarContactosEndpoint({} as Request, res)
expect(json).toHaveBeenCalledWith(CONTACTOS)
})
it('propaga el error si Monday falla (el error handler responde 5xx)', async () => {
mondayMock.getContactos.mockRejectedValue(new Error('Monday caído'))
const { res } = resFake()
await expect(listarContactosEndpoint({} as Request, res)).rejects.toThrow('Monday caído')
})
})

View File

@@ -0,0 +1,10 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
/**
* GET /api/contactos (Bearer) → contactos del tablero Contactos de Monday
* (nombre, teléfono, email, estado), lo más reciente primero, máx 100.
*/
export async function listarContactosEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await mondayService.getContactos())
}

View File

@@ -26,6 +26,18 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
)
// TODOS los usuarios del workspace de Monday (el "último cambio" de un lead
// puede ser de alguien sin cuenta en el ecosistema); si ya estaba mapeado
// gana el nombre del ecosistema. Si Monday falla, el resumen sale igual.
try {
for (const u of await mondayService.getUsuariosWorkspace()) {
if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre)
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudieron listar los usuarios del workspace de Monday:', err)
}
// Capturas de afluencia registradas por el usuario (registro local)
const capturas = await prisma.leadCaptura.findMany({
where: { usuarioId: usuario.id },

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import type { Usuario } from '@prisma/client'
import {
actualizarRolEndpoint,
actualizarVinculoMondayEndpoint,
listarUsuariosMondayEndpoint,
} from './usuarios.controller.js'
@@ -91,6 +92,7 @@ describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () =
rol: 'vendedor',
asignacion: 'MONTEBELLO',
mondayUserId: 13676,
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
})
})
@@ -137,3 +139,57 @@ describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () =
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
})
describe('actualizarRolEndpoint (PATCH /api/usuarios/:id/rol)', () => {
function reqAdmin(overrides: Partial<Request> = {}): Request {
return {
body: { rol: 'escrituracion' },
params: { id: 'u-9' },
usuarioToken: { sub: 'u-1', username: 'admin', rol: 'admin' },
...overrides,
} as Request
}
it('admin con rol válido: actualiza y devuelve el perfil con sus módulos', async () => {
prismaMock.usuario.update.mockResolvedValue(usuarioFake({ id: 'u-9', rol: 'escrituracion' }))
const { res, json } = resFake()
await actualizarRolEndpoint(reqAdmin(), res)
expect(prismaMock.usuario.update).toHaveBeenCalledWith({
where: { id: 'u-9' },
data: { rol: 'escrituracion' },
})
expect(json).toHaveBeenCalledWith(
expect.objectContaining({ rol: 'escrituracion', modulos: expect.arrayContaining(['cobranza']) }),
)
})
it('sin rol admin: 403 y no toca la BD', async () => {
const { res } = resFake()
const error = await actualizarRolEndpoint(
reqAdmin({ usuarioToken: { sub: 'u-2', username: 'x', rol: 'vendedor' } }),
res,
).catch((e: unknown) => e)
expect(error).toMatchObject({ status: 403 })
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
it('rol inválido: 400 y no toca la BD', async () => {
const { res } = resFake()
const error = await actualizarRolEndpoint(reqAdmin({ body: { rol: 'coordinador' } }), res).catch(
(e: unknown) => e,
)
expect(error).toMatchObject({ status: 400 })
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
it('sin token verificado: 400', async () => {
const { res } = resFake()
await expect(actualizarRolEndpoint(reqAdmin({ usuarioToken: undefined }), res)).rejects.toThrow(
'Falta el token verificado',
)
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
})

View File

@@ -3,7 +3,8 @@ import { z } from 'zod'
import { mondayService } from '../services/monday.service.js'
import { prisma } from '../services/prisma.js'
import { aPublico } from '../services/auth.service.js'
import { BadRequestError } from '../utils/errors.js'
import { esRolValido } from '../config/roles.js'
import { AppError, BadRequestError } from '../utils/errors.js'
/**
* Vínculo usuario del ecosistema ↔ usuario de Monday (people). Es el mapeo
@@ -42,3 +43,27 @@ export async function actualizarVinculoMondayEndpoint(req: Request, res: Respons
})
res.json(aPublico(usuario))
}
const cambiarRolSchema = z.object({
rol: z.string().min(1),
})
/**
* PATCH /api/usuarios/:id/rol (Bearer, solo admin) { rol } → usuario público
* actualizado. Solo roles de la matriz nueva (config/roles.ts); los legacy
* ('coordinador', 'corporativo') ya no se asignan.
*/
export async function actualizarRolEndpoint(req: Request, res: Response): Promise<void> {
if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado')
if (req.usuarioToken.rol !== 'admin') {
throw new AppError(403, 'Solo un administrador puede cambiar roles')
}
const { rol } = cambiarRolSchema.parse(req.body)
if (!esRolValido(rol)) throw new BadRequestError(`Rol inválido: ${rol}`)
const usuario = await prisma.usuario.update({
where: { id: req.params.id as string },
data: { rol },
})
res.json(aPublico(usuario))
}

View File

@@ -53,10 +53,12 @@ import {
descartarLeadEndpoint,
} from '../controllers/leads.controller.js'
import {
actualizarRolEndpoint,
actualizarVinculoMondayEndpoint,
listarUsuariosMondayEndpoint,
} from '../controllers/usuarios.controller.js'
import { resumenMiDiaEndpoint } from '../controllers/mi-dia.controller.js'
import { listarContactosEndpoint } from '../controllers/contactos.controller.js'
import { webhookMonday } from '../controllers/webhooks.controller.js'
import {
correrRoundRobin,
@@ -135,6 +137,8 @@ apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
apiRouter.get('/contactos', listarContactosEndpoint)
// GET estático: va ANTES de /leads/:itemId para no colisionar con el parámetro
apiRouter.get('/leads/buscar', buscarLeadsEndpoint)
apiRouter.post('/leads', crearLeadEndpoint)
@@ -146,6 +150,7 @@ apiRouter.post('/afluencias', crearAfluenciaEndpoint)
apiRouter.get('/monday/usuarios', listarUsuariosMondayEndpoint)
apiRouter.patch('/usuarios/me/monday', actualizarVinculoMondayEndpoint)
apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
apiRouter.get('/reportes/resumen', resumenReportes)

View File

@@ -54,6 +54,7 @@ describe('login', () => {
rol: 'vendedor',
asignacion: 'MONTEBELLO',
mondayUserId: null,
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
})
expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos')
})
@@ -98,6 +99,12 @@ describe('me', () => {
expect(resultado.nombre).toBe('ERICK ISRAEL PEÑA RAMOS')
})
it('incluye los módulos visibles del rol (matriz de config/roles)', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ rol: 'direccion_general' }))
const resultado = await me({ sub: 'u-1', username: 'x', rol: 'direccion_general' })
expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat'])
})
it('rechaza si el usuario ya no existe o está inactivo', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(null)
await expect(me({ sub: 'x', username: 'x', rol: 'vendedor' })).rejects.toThrow('Credenciales inválidas')

View File

@@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken'
import type { Usuario } from '@prisma/client'
import { prisma } from './prisma.js'
import { config } from '../config/index.js'
import { modulosDeRol, type Modulo } from '../config/roles.js'
import { AppError } from '../utils/errors.js'
export interface UsuarioPublico {
@@ -11,6 +12,8 @@ export interface UsuarioPublico {
asignacion: string | null
// User ID de Monday vinculado (null = sin vincular; ver PATCH /usuarios/me/monday)
mondayUserId: number | null
// Módulos del ecosistema visibles para el rol (matriz de config/roles.ts)
modulos: Modulo[]
}
export interface LoginResult {
@@ -37,6 +40,7 @@ export function aPublico(usuario: Usuario): UsuarioPublico {
rol: usuario.rol,
asignacion: usuario.asignacion,
mondayUserId: usuario.mondayUserId,
modulos: modulosDeRol(usuario.rol),
}
}

View File

@@ -1,12 +1,19 @@
import { describe, expect, it } from 'vitest'
import {
detalleEventoUltimoCambio,
esEstadoFinalApartado,
esEstadoTerminalLead,
esEtapaTerminal,
resumenMiDia,
type UsuarioMiDia,
} from './mi-dia.service.js'
import type { ActividadMonday, ApartadoMonday, LeadMonday, OportunidadMonday } from './monday.service.js'
import type {
ActividadMonday,
ApartadoMonday,
LeadMonday,
OportunidadMonday,
UltimoCambioMonday,
} from './monday.service.js'
const MI_ID = 106736727
const OTRO_ID = 111111111
@@ -71,6 +78,8 @@ function mondayMock(datos: {
oportunidades?: OportunidadMonday[]
actividades?: ActividadMonday[]
apartados?: ApartadoMonday[]
/** boardId → (itemId → último cambio); boards de test: Leads '444', Oportunidades '555' */
ultimosCambios?: Record<string, Map<string, UltimoCambioMonday>>
}) {
return {
async getLeads() {
@@ -85,6 +94,9 @@ function mondayMock(datos: {
async getApartados() {
return datos.apartados ?? []
},
async getUltimosCambios(boardId: string, _itemIds: string[]) {
return datos.ultimosCambios?.[boardId] ?? new Map<string, UltimoCambioMonday>()
},
}
}
@@ -118,6 +130,63 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
expect(r.misLeads[0]?.telefono).toBe('4771234567')
})
it('ultimoCambio: último evento por item, agrupando ids por fuente y resolviendo el nombre', async () => {
const monday = mondayMock({
leads: [lead({ id: 'l1', updatedAt: '2026-08-28T08:00:00Z' })],
oportunidades: [oportunidad({ id: 'o1', updatedAt: '2026-08-27T08:00:00Z' })],
ultimosCambios: {
'444': new Map([['l1', { fechaISO: '2026-08-28T10:00:00.000Z', usuarioId: MI_ID, evento: 'update_status' }]]),
'555': new Map([['o1', { fechaISO: '2026-08-28T09:00:00.000Z', usuarioId: OTRO_ID, evento: 'create_pulse' }]]),
},
})
const nombres = new Map([[MI_ID, 'Martin Zavala']]) // OTRO_ID no está mapeado
const r = await resumenMiDia(monday, vendedor, nombres, [], [], AHORA)
expect(r.misLeads.find((l) => l.id === 'l1')?.ultimoCambio).toEqual({
fechaISO: '2026-08-28T10:00:00.000Z',
usuario: 'Martin Zavala',
detalle: 'Actualización',
})
// usuarioId sin nombre en el mapa → fallback null
expect(r.misLeads.find((l) => l.id === 'o1')?.ultimoCambio).toEqual({
fechaISO: '2026-08-28T09:00:00.000Z',
usuario: null,
detalle: 'Alta',
})
})
it('ultimoCambio: null cuando el item no tiene eventos en el activity log', async () => {
const monday = mondayMock({
leads: [lead({ id: 'l1' })],
ultimosCambios: { '444': new Map() }, // sin eventos
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.misLeads[0]?.ultimoCambio).toBeNull()
})
it('ultimoCambio: usuarioId null (evento de automatización) → usuario null', async () => {
const monday = mondayMock({
leads: [lead({ id: 'l1' })],
ultimosCambios: {
'444': new Map([['l1', { fechaISO: '2026-08-28T10:00:00.000Z', usuarioId: null, evento: 'move_to_group' }]]),
},
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.misLeads[0]?.ultimoCambio).toEqual({
fechaISO: '2026-08-28T10:00:00.000Z',
usuario: null,
detalle: 'Cambio de grupo',
})
})
it('detalleEventoUltimoCambio: humaniza los eventos conocidos y pasa el crudo si no', () => {
expect(detalleEventoUltimoCambio('create_pulse')).toBe('Alta')
expect(detalleEventoUltimoCambio('create_update')).toBe('Nota agregada')
expect(detalleEventoUltimoCambio('update_column_value')).toBe('Actualización')
expect(detalleEventoUltimoCambio('update_status')).toBe('Actualización')
expect(detalleEventoUltimoCambio('move_to_group')).toBe('Cambio de grupo')
expect(detalleEventoUltimoCambio('update_name')).toBe('update_name')
})
it('misOportunidades: conteo de MIS oportunidades por etapa (incluye terminales)', async () => {
const monday = mondayMock({
oportunidades: [

View File

@@ -1,4 +1,5 @@
import type { MondayService } from './monday.service.js'
import { config } from '../config/index.js'
/**
* Agregador "Mi día": lo que un vendedor necesita al abrir la app /hoy.
@@ -39,6 +40,8 @@ export interface LeadMiDia {
updatedAt: string
/** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */
fuente: 'lead' | 'oportunidad'
/** Último evento del activity log del board; null si el item no tiene eventos */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
}
export interface ActividadMiDia {
@@ -105,10 +108,30 @@ export interface ResumenMiDia {
}
}
type MondayMiDia = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'getActividades' | 'getApartados'>
type MondayMiDia = Pick<
MondayService,
'getLeads' | 'getOportunidades' | 'getActividades' | 'getApartados' | 'getUltimosCambios'
>
const MS_DIA = 24 * 60 * 60 * 1000
/** Texto legible del evento del activity log (los nombres de columna no vienen legibles en el payload) */
export function detalleEventoUltimoCambio(evento: string): string {
switch (evento) {
case 'create_pulse':
return 'Alta'
case 'create_update':
return 'Nota agregada'
case 'update_column_value':
case 'update_status':
return 'Actualización'
case 'move_to_group':
return 'Cambio de grupo'
default:
return evento
}
}
/** Fecha local 'YYYY-MM-DD' desplazada n días desde `base` */
function fechaLocal(base: Date, dias: number): string {
const d = new Date(base.getTime() + dias * MS_DIA)
@@ -180,6 +203,7 @@ export async function resumenMiDia(
origen: l.origen,
updatedAt: l.updatedAt,
fuente: 'lead',
ultimoCambio: null,
}))
const mios = sinMapeo ? [] : oportunidades.filter((o) => o.asesorIds.includes(miId))
const misLeadsOportunidades: LeadMiDia[] = mios
@@ -193,11 +217,37 @@ export async function resumenMiDia(
origen: o.origen,
updatedAt: o.updatedAt,
fuente: 'oportunidad',
ultimoCambio: null,
}))
const misLeads: LeadMiDia[] = [...misLeadsLeads, ...misLeadsOportunidades].sort((a, b) =>
b.updatedAt.localeCompare(a.updatedAt),
)
// "Último cambio" por item: UNA llamada al activity log por tablero (máx 2),
// agrupando los ids por fuente. El usuario se resuelve con el mapa
// mondayUserId → nombre que pasa el controller (ecosistema + workspace).
const [cambiosLeads, cambiosOportunidades] = await Promise.all([
monday.getUltimosCambios(
config.monday.boards.leads,
misLeads.filter((l) => l.fuente === 'lead').map((l) => l.id),
),
monday.getUltimosCambios(
config.monday.boards.oportunidades,
misLeads.filter((l) => l.fuente === 'oportunidad').map((l) => l.id),
),
])
const cambiosPorFuente = { lead: cambiosLeads, oportunidad: cambiosOportunidades }
for (const lead of misLeads) {
const cambio = cambiosPorFuente[lead.fuente].get(lead.id)
lead.ultimoCambio = cambio
? {
fechaISO: cambio.fechaISO,
usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null,
detalle: detalleEventoUltimoCambio(cambio.evento),
}
: null
}
// Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen
// del pipeline personal, no la lista de trabajo activo)
const porEtapa: Record<string, number> = {}

View File

@@ -838,4 +838,203 @@ describe('MondayService', () => {
expect(Object.keys(parsed).sort()).toEqual(['color_e', 'date_fa', 'date_fl', 'text_u'])
})
})
describe('getUltimosCambios (activity log por item)', () => {
const log = (id: string, event: string, createdAt: string, userId: string, pulseId: number) => ({
id,
event,
created_at: createdAt,
user_id: userId,
data: JSON.stringify({ pulse_id: pulseId }),
})
it('itemIds vacío: Map vacío sin llamar a Monday', async () => {
const llamadas = mockearFetch(() => respuestaOk({}))
const resultado = await crearServicio().getUltimosCambios('444', [])
expect(resultado.size).toBe(0)
expect(llamadas).toHaveLength(0)
})
it('una query con todos los itemIds; se queda con el evento MÁS RECIENTE por item', async () => {
const llamadas = mockearFetch(() =>
respuestaOk({
boards: [
{
activity_logs: [
log('a1', 'create_pulse', '1757000000000000', '106736727', 1),
log('a2', 'update_status', '1757001000000000', '111111111', 1),
log('a3', 'create_update', '1757000500000000', '106736727', 2),
],
},
],
}),
)
const resultado = await crearServicio().getUltimosCambios('444', ['1', '2'])
expect(llamadas).toHaveLength(1)
expect(llamadas[0]?.body.variables).toMatchObject({ ids: ['444'], itemIds: ['1', '2'], limit: 10 })
// created_at en µs (> 1e15) → se divide entre 1e6
expect(resultado.get('1')).toEqual({
fechaISO: new Date(1757001000000).toISOString(),
usuarioId: 111111111,
evento: 'update_status',
})
expect(resultado.get('2')).toEqual({
fechaISO: new Date(1757000500000).toISOString(),
usuarioId: 106736727,
evento: 'create_update',
})
})
it('created_at en ms (≤ 1e15) se usa directo; user_id vacío → null; payload sin pulse_id se ignora', async () => {
mockearFetch(() =>
respuestaOk({
boards: [
{
activity_logs: [
log('a1', 'update_column_value', '1757000000000', '', 7),
{ id: 'a2', event: 'x', created_at: '1757000000000000', user_id: '5', data: 'no-json' },
],
},
],
}),
)
const resultado = await crearServicio().getUltimosCambios('444', ['7'])
expect(resultado.size).toBe(1)
expect(resultado.get('7')).toEqual({
fechaISO: new Date(1757000000000).toISOString(),
usuarioId: null,
evento: 'update_column_value',
})
})
it('created_at de 17 dígitos (100ns, formato real de Monday) → /1e4 a ms', async () => {
mockearFetch(() =>
respuestaOk({
boards: [{ activity_logs: [log('a1', 'create_pulse', '17888408206077900', '106736727', 3)] }],
}),
)
const resultado = await crearServicio().getUltimosCambios('444', ['3'])
const fecha = resultado.get('3')?.fechaISO
expect(fecha?.slice(0, 4)).toBe('2026') // sin la regla de 17 dígitos saldría año 2558+
})
})
describe('crearMotivoNoCompra (grupo por categoría)', () => {
const COLUMNAS_MOTIVOS = [
{ id: 'color_cat', title: 'Categoria', type: 'status' },
{ id: 'text_des', title: 'Descripcion', type: 'text' },
]
function mockearBoardMotivos(): LlamadaFetch[] {
return mockearFetch((body) => {
if (body.query.includes('settings_str')) {
return respuestaOk({
boards: [
{ columns: [{ id: 'color_cat', title: 'Categoria', settings_str: '{"labels":{"1":"Precio","2":"Timing"}}' }] },
],
})
}
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_MOTIVOS }] })
return respuestaOk({ create_item: { id: 'motivo-1' } })
})
}
it('crea el item directo en el grupo de su categoría', async () => {
const llamadas = mockearBoardMotivos()
const id = await crearServicio().crearMotivoNoCompra({
nombre: 'Precio — Lead Prueba',
categoria: 'Precio',
descripcion: 'Lo encontró caro',
})
expect(id).toBe('motivo-1')
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
expect(mutacion?.body.variables['boardId']).toBe('888') // tablero Motivos No Compra
expect(mutacion?.body.variables['groupId']).toBe('group_mm6sze0z') // grupo Precio
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
expect(parsed['color_cat']).toEqual({ label: 'Precio' })
expect(parsed['text_des']).toBe('Lo encontró caro')
})
it('normaliza trim/case al buscar el grupo (Ubicacion sin acento)', async () => {
const llamadas = mockearBoardMotivos()
await crearServicio().crearMotivoNoCompra({ nombre: 'U — Lead', categoria: ' ubicacion ' })
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
expect(mutacion?.body.variables['groupId']).toBe('group_mm6sqern')
})
it('categoría sin grupo mapeado: group_id null (grupo default) sin tronar', async () => {
const llamadas = mockearBoardMotivos()
const id = await crearServicio().crearMotivoNoCompra({ nombre: 'Otro — Lead', categoria: 'Otro raro' })
expect(id).toBe('motivo-1')
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
expect(mutacion?.body.variables['groupId']).toBeNull()
})
})
describe('getContactos (módulo Contactos de la landing)', () => {
const COLUMNAS_CONTACTOS = [
{ id: 'phone_tel', title: 'Teléfono', type: 'phone' },
{ id: 'email_em', title: 'Email', type: 'email' },
{ id: 'color_est', title: 'Estado', type: 'status' },
]
const itemContacto = (id: string, updatedAt: string, estado: { text: string; value: string | null }) => ({
id,
name: `Contacto ${id}`,
updated_at: updatedAt,
column_values: [
{ id: 'phone_tel', text: `551111000${id}`, value: '{"phone":"5511110000"}' },
{ id: 'email_em', text: `c${id}@correo.mx`, value: '{"email":"c@correo.mx"}' },
{ id: 'color_est', text: estado.text, value: estado.value },
],
})
it('mapea nombre/teléfono/email/estado y ordena por updated_at desc', async () => {
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_CONTACTOS }] })
return respuestaOk({
boards: [
{
items_page: {
cursor: null,
items: [
itemContacto('1', '2026-08-01T10:00:00Z', { text: 'CLIENTE', value: '{"index":1}' }),
itemContacto('2', '2026-09-01T10:00:00Z', { text: 'PROSPECTO', value: '{"index":2}' }),
],
},
},
],
})
})
const contactos = await crearServicio().getContactos()
expect(contactos.map((c) => c.id)).toEqual(['2', '1'])
expect(contactos[0]).toEqual({
id: '2',
nombre: 'Contacto 2',
telefono: '5511110002',
email: 'c2@correo.mx',
estado: 'PROSPECTO',
updatedAt: '2026-09-01T10:00:00Z',
})
})
it('estado con value null (nunca escrito) se lee como vacío, no el default del tablero', async () => {
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_CONTACTOS }] })
return respuestaOk({
boards: [
{
items_page: {
cursor: null,
items: [itemContacto('1', '2026-09-01T10:00:00Z', { text: 'PROSPECTO', value: null })],
},
},
],
})
})
const contactos = await crearServicio().getContactos()
expect(contactos[0]?.estado).toBe('')
})
})
})

View File

@@ -81,6 +81,16 @@ export interface ApartadoMonday {
estado: string // label de la columna status 'Estado'
}
/** 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
@@ -116,6 +126,22 @@ export interface UsuarioMonday {
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
}
/** Ú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 }
@@ -135,6 +161,21 @@ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
const CACHE_UNIDADES_MS = 5 * 60 * 1000 // 5 minutos
/**
* Grupo del tablero Motivos No Compra por categoría (ids verificados sep-2026).
* El descarte crea el motivo directo en su grupo; si la categoría no está
* mapeada, el item cae en el grupo default del tablero.
*/
export const GRUPOS_MOTIVOS_NO_COMPRA: Record<string, string> = {
Timing: 'group_mm6spcsj',
Cliente: 'group_mm6sp7nr',
Competencia: 'group_mm6sbdfy',
Financiamiento: 'group_mm6ssymc',
Ubicacion: 'group_mm6sqern', // sin acento: así está el grupo en Monday
Producto: 'group_mm6s7j0b',
Precio: 'group_mm6sze0z',
}
export interface MondayServiceOptions {
apiKey?: string
apiUrl?: string
@@ -418,12 +459,12 @@ export class MondayService {
return JSON.stringify(out)
}
private async crearItem(boardId: string, nombre: string, columnValues: string): Promise<string> {
private async crearItem(boardId: string, nombre: string, columnValues: string, groupId?: string): Promise<string> {
const data = await this.graphql<{ create_item: { id: string } }>(
`mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) {
create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues) { id }
`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 },
{ boardId, itemName: nombre, columnValues, groupId: groupId ?? null },
)
return data.create_item.id
}
@@ -660,7 +701,8 @@ export class MondayService {
// ---------- Motivos No Compra / Actividades (tableros CRM FRASA) ----------
/** Item en el tablero Motivos No Compra (nombre = "<motivo> — <item origen>") */
/** Item en el tablero Motivos No Compra (nombre = "<motivo> — <item origen>"),
* creado directo en el grupo de su categoría (si está mapeada) */
async crearMotivoNoCompra(datos: DatosMotivoNoCompra): Promise<string> {
const boardId = config.monday.boards.motivos
if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_MOTIVOS no está configurado')
@@ -668,7 +710,11 @@ export class MondayService {
Categoria: datos.categoria,
Descripcion: datos.descripcion,
})
return this.crearItem(boardId, datos.nombre, columnValues)
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) */
@@ -904,6 +950,52 @@ export class MondayService {
// ---------- Mi día (CRM ligero para vendedores) ----------
/**
* Último evento del activity log por item: UNA query por tablero con todos
* los itemIds (limit = items × 5, por si hay varios eventos por item). El
* item se identifica por el pulse_id del payload `data` (JSON string). El
* created_at viene como string numérico; por magnitud se normaliza a ms:
* > 1e16 ⇒ centésimas de µs (así llega hoy, 17 dígitos), > 1e14 ⇒ µs,
* si no ms. itemIds vacío ⇒ Map vacío sin llamar a Monday.
*/
async getUltimosCambios(boardId: string, itemIds: string[]): Promise<Map<string, UltimoCambioMonday>> {
const resultado = new Map<string, UltimoCambioMonday>()
if (itemIds.length === 0) return resultado
const data = await this.graphql<{ boards: { activity_logs: ActivityLogMonday[] }[] }>(
`query ($ids: [ID!], $itemIds: [ID!], $limit: Int!) {
boards(ids: $ids) {
activity_logs(item_ids: $itemIds, limit: $limit) { id event created_at user_id data }
}
}`,
{ ids: [boardId], itemIds, limit: itemIds.length * 5 },
)
for (const log of data.boards[0]?.activity_logs ?? []) {
let itemId: string | undefined
try {
const datos = JSON.parse(log.data) as { pulse_id?: number | string }
itemId = datos.pulse_id === undefined ? undefined : String(datos.pulse_id)
} catch {
continue
}
if (itemId === undefined) continue
const crudo = Number(log.created_at)
if (!Number.isFinite(crudo)) continue
// Normaliza a ms por magnitud: 100ns (17 dígitos) /1e4, µs /1e3, ms directo
const ms = crudo > 1e16 ? crudo / 1e4 : crudo > 1e14 ? crudo / 1e3 : crudo
const fechaISO = new Date(ms).toISOString()
// Las fechas ISO comparan bien como strings: solo nos quedamos con el más reciente
const actual = resultado.get(itemId)
if (actual && actual.fechaISO >= fechaISO) continue
const userId = Number(log.user_id)
resultado.set(itemId, {
fechaISO,
usuarioId: Number.isFinite(userId) && userId > 0 ? userId : null,
evento: log.event,
})
}
return resultado
}
/** Parsea el value JSON de una columna people a los user IDs (solo personas) */
private peopleIds(raw: string | null | undefined): number[] {
if (!raw) return []
@@ -1028,6 +1120,70 @@ export class MondayService {
})
}
/**
* Contactos del tablero Contactos (módulo Contactos de la landing): nombre,
* teléfono, email y estado, ordenados por updated_at desc, máx 100.
*/
async getContactos(): Promise<ContactoMonday[]> {
const boardId = config.monday.boards.contactos
const cols = await this.columnasDe(boardId)
const colTelefono = cols.get('Teléfono')
const colEmail = cols.get('Email')
const colEstado = cols.get('Estado')
const items: ItemConFechas[] = []
let cursor: string | null = null
do {
const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } =
cursor === null
? await this.graphql(
`query ($ids: [ID!]) {
boards(ids: $ids) {
items_page(limit: 100) {
cursor
items { id name updated_at column_values { id text value } }
}
}
}`,
{ ids: [boardId] },
)
: await this.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) {
items_page(limit: 100, cursor: $cursor) {
cursor
items { id name updated_at column_values { id text value } }
}
}
}`,
{ ids: [boardId], cursor },
)
const page = data.boards[0]?.items_page
if (!page) break
items.push(...page.items)
cursor = page.cursor
} while (cursor !== null)
return items
.map((item) => {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
// Misma corrección que mapearLead: status nunca escrito → value null y
// `text` muestra el default del tablero; el estado real es vacío
const estadoCv = colEstado ? porId.get(colEstado.id) : undefined
const estado = estadoCv?.value ? (estadoCv.text ?? '') : ''
return {
id: item.id,
nombre: item.name,
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
estado,
updatedAt: item.updated_at,
}
})
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, 100)
}
/**
* Labels de una columna status/dropdown (settings_str del board).
* Monday los devuelve como objeto {index: label} o como arreglo [{id, name}].