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,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))
}