feat(auditoria): mostrar el nombre del usuario en lugar del id (tabla y filtro)

This commit is contained in:
2026-09-17 08:54:56 +00:00
parent 9643f866cc
commit 84582ce6a2
6 changed files with 66 additions and 19 deletions

View File

@@ -8,6 +8,9 @@ const prismaMock = vi.hoisted(() => ({
auditoriaLog: { auditoriaLog: {
findMany: vi.fn(), findMany: vi.fn(),
}, },
usuario: {
findMany: vi.fn(),
},
})) }))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
@@ -29,13 +32,14 @@ beforeEach(() => {
}) })
describe('listarAuditoriaEndpoint (GET /api/auditoria)', () => { describe('listarAuditoriaEndpoint (GET /api/auditoria)', () => {
it('retorna logs de auditoría para admin', async () => { it('retorna logs de auditoría para admin (con el nombre del usuario)', async () => {
const logsFake = [{ id: 'log-1', usuarioId: 'u1', accion: 'CREAR_LEAD', entidad: 'lead', entidadId: 'l1', detalle: null, createdAt: new Date() }] const logsFake = [{ id: 'log-1', usuarioId: 'u1', accion: 'CREAR_LEAD', entidad: 'lead', entidadId: 'l1', detalle: null, createdAt: new Date() }]
prismaMock.auditoriaLog.findMany.mockResolvedValue(logsFake) prismaMock.auditoriaLog.findMany.mockResolvedValue(logsFake)
prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u1', nombre: 'Martin Zavala' }])
const { res, json } = resFake() const { res, json } = resFake()
await listarAuditoriaEndpoint(reqFake({}, { sub: 'admin1', rol: 'admin' }), res) await listarAuditoriaEndpoint(reqFake({}, { sub: 'admin1', rol: 'admin' }), res)
expect(json).toHaveBeenCalledWith(logsFake) expect(json).toHaveBeenCalledWith([{ ...logsFake[0], usuarioNombre: 'Martin Zavala' }])
}) })
it('aplica filtros de entidad y usuarioId', async () => { it('aplica filtros de entidad y usuarioId', async () => {

View File

@@ -7,6 +7,9 @@ const prismaMock = vi.hoisted(() => ({
create: vi.fn(), create: vi.fn(),
findMany: vi.fn(), findMany: vi.fn(),
}, },
usuario: {
findMany: vi.fn(),
},
})) }))
vi.mock('./prisma.js', () => ({ prisma: prismaMock })) vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
@@ -48,9 +51,10 @@ describe('registrar', () => {
}) })
describe('buscar', () => { describe('buscar', () => {
it('sin filtros: retorna todos los logs ordenados por createdAt desc', async () => { it('sin filtros: retorna los logs con el nombre del usuario resuelto', async () => {
const logsFake = [{ id: 'log-1' }] const logsFake = [{ id: 'log-1', usuarioId: 'u1' }]
prismaMock.auditoriaLog.findMany.mockResolvedValue(logsFake) prismaMock.auditoriaLog.findMany.mockResolvedValue(logsFake)
prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u1', nombre: 'Martin Zavala' }])
const resultado = await buscar({}) const resultado = await buscar({})
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({ expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
@@ -58,11 +62,23 @@ describe('buscar', () => {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 100, take: 100,
}) })
expect(resultado).toEqual(logsFake) expect(resultado).toEqual([{ id: 'log-1', usuarioId: 'u1', usuarioNombre: 'Martin Zavala' }])
})
it('si el usuario ya no existe usa el id; "sistema" se muestra como Sistema', async () => {
prismaMock.auditoriaLog.findMany.mockResolvedValue([
{ id: 'log-2', usuarioId: 'fantasma' },
{ id: 'log-3', usuarioId: 'sistema' },
])
prismaMock.usuario.findMany.mockResolvedValue([])
const resultado = await buscar({})
expect(resultado.map((r) => r.usuarioNombre)).toEqual(['fantasma', 'Sistema'])
}) })
it('con filtros de entidad y usuarioId', async () => { it('con filtros de entidad y usuarioId', async () => {
prismaMock.auditoriaLog.findMany.mockResolvedValue([]) prismaMock.auditoriaLog.findMany.mockResolvedValue([])
prismaMock.usuario.findMany.mockResolvedValue([])
await buscar({ entidad: 'lead', usuarioId: 'u1' }) await buscar({ entidad: 'lead', usuarioId: 'u1' })
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({ expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
where: { entidad: 'lead', usuarioId: 'u1' }, where: { entidad: 'lead', usuarioId: 'u1' },
@@ -75,6 +91,7 @@ describe('buscar', () => {
const desde = new Date('2026-09-01') const desde = new Date('2026-09-01')
const hasta = new Date('2026-09-07') const hasta = new Date('2026-09-07')
prismaMock.auditoriaLog.findMany.mockResolvedValue([]) prismaMock.auditoriaLog.findMany.mockResolvedValue([])
prismaMock.usuario.findMany.mockResolvedValue([])
await buscar({ desde, hasta }) await buscar({ desde, hasta })
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({ expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
where: { createdAt: { gte: desde, lte: hasta } }, where: { createdAt: { gte: desde, lte: hasta } },
@@ -85,6 +102,7 @@ describe('buscar', () => {
it('respeta el límite personalizado', async () => { it('respeta el límite personalizado', async () => {
prismaMock.auditoriaLog.findMany.mockResolvedValue([]) prismaMock.auditoriaLog.findMany.mockResolvedValue([])
prismaMock.usuario.findMany.mockResolvedValue([])
await buscar({ entidad: 'lead' }, 10) await buscar({ entidad: 'lead' }, 10)
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({ expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
where: { entidad: 'lead' }, where: { entidad: 'lead' },

View File

@@ -30,6 +30,11 @@ export function registrar(
}) })
} }
/**
* Lista registros de auditoría con el NOMBRE del usuario resuelto (la UI
* muestra nombres, no ids). Si el usuario ya no existe se devuelve el id como
* respaldo ('sistema' se muestra como 'Sistema').
*/
export async function buscar(filtros: FiltrosAuditoria, limite = 100) { export async function buscar(filtros: FiltrosAuditoria, limite = 100) {
const where: Record<string, unknown> = {} const where: Record<string, unknown> = {}
if (filtros.entidad !== undefined) where['entidad'] = filtros.entidad if (filtros.entidad !== undefined) where['entidad'] = filtros.entidad
@@ -41,9 +46,22 @@ export async function buscar(filtros: FiltrosAuditoria, limite = 100) {
...(filtros.hasta !== undefined ? { lte: filtros.hasta } : {}), ...(filtros.hasta !== undefined ? { lte: filtros.hasta } : {}),
} }
} }
return prisma.auditoriaLog.findMany({ const logs = await prisma.auditoriaLog.findMany({
where, where,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: limite, take: limite,
}) })
const ids = [...new Set(logs.map((l) => l.usuarioId))]
const usuarios = ids.length > 0
? await prisma.usuario.findMany({ where: { id: { in: ids } }, select: { id: true, nombre: true } })
: []
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
return logs.map((l) => ({
...l,
usuarioNombre:
nombrePorId.get(l.usuarioId) ??
(l.usuarioId === 'sistema' || l.usuarioId === 'desconocido' ? 'Sistema' : l.usuarioId),
}))
} }

View File

@@ -23,8 +23,8 @@ const respuestaOk = (data: unknown) =>
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }) new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
const auditoriaFake = [ const auditoriaFake = [
{ id: 'a1', usuarioId: 'admin', accion: 'LEAD_CREADO', entidad: 'lead', entidadId: 'l1', detalle: 'Lead nuevo', createdAt: '2026-09-01T10:00:00Z' }, { id: 'a1', usuarioId: 'admin', usuarioNombre: 'Administrador FRASA', accion: 'LEAD_CREADO', entidad: 'lead', entidadId: 'l1', detalle: 'Lead nuevo', createdAt: '2026-09-01T10:00:00Z' },
{ id: 'a2', usuarioId: 'vendedor1', accion: 'ETAPA_CAMBIADA', entidad: 'oportunidad', entidadId: 'o1', detalle: 'Cambiado a Cotizador', createdAt: '2026-09-02T14:00:00Z' }, { id: 'a2', usuarioId: 'vendedor1', usuarioNombre: 'Martin Zavala', accion: 'ETAPA_CAMBIADA', entidad: 'oportunidad', entidadId: 'o1', detalle: 'Cambiado a Cotizador', createdAt: '2026-09-02T14:00:00Z' },
] ]
let fetchMock: ReturnType<typeof vi.fn<FetchFn>> let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
@@ -49,9 +49,9 @@ describe('AuditoriaLog', () => {
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce())
expect(screen.getByText('LEAD_CREADO')).toBeTruthy() expect(screen.getByText('LEAD_CREADO')).toBeTruthy()
expect(screen.getByText('ETAPA_CAMBIADA')).toBeTruthy() expect(screen.getByText('ETAPA_CAMBIADA')).toBeTruthy()
// User IDs may appear in multiple cells; verify they exist // La tabla muestra el NOMBRE del usuario (el id solo como respaldo/tooltip)
expect(screen.getAllByText('admin').length).toBeGreaterThanOrEqual(1) expect(screen.getAllByText('Administrador FRASA').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('vendedor1').length).toBeGreaterThanOrEqual(1) expect(screen.getAllByText('Martin Zavala').length).toBeGreaterThanOrEqual(1)
}) })
it('muestra "Sin registros" cuando no hay datos', async () => { it('muestra "Sin registros" cuando no hay datos', async () => {

View File

@@ -15,7 +15,7 @@ export function AuditoriaLog() {
const [usuario, setUsuario] = useState('') const [usuario, setUsuario] = useState('')
const [fechaDesde, setFechaDesde] = useState('') const [fechaDesde, setFechaDesde] = useState('')
const [fechaHasta, setFechaHasta] = useState('') const [fechaHasta, setFechaHasta] = useState('')
const [usuariosLista, setUsuariosLista] = useState<string[]>([]) const [usuariosLista, setUsuariosLista] = useState<[string, string][]>([])
const cargar = useCallback(async () => { const cargar = useCallback(async () => {
setCargando(true) setCargando(true)
@@ -29,9 +29,12 @@ export function AuditoriaLog() {
}) })
setItems(data) setItems(data)
setPagina(1) setPagina(1)
// Extraer usuarios únicos para el select // Usuarios únicos para el select: se muestra el nombre y se filtra por id
const usrs = [...new Set(data.map((i) => i.usuarioId).filter(Boolean))] as string[] const porId = new Map<string, string>()
setUsuariosLista(usrs) for (const i of data) {
if (i.usuarioId) porId.set(i.usuarioId, i.usuarioNombre ?? i.usuarioId)
}
setUsuariosLista([...porId.entries()].sort((a, b) => a[1].localeCompare(b[1])))
} catch { } catch {
setError('No se pudieron cargar los registros de auditoría.') setError('No se pudieron cargar los registros de auditoría.')
} finally { } finally {
@@ -74,9 +77,9 @@ export function AuditoriaLog() {
className="ml-2 rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]" className="ml-2 rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
> >
<option value="">Todos</option> <option value="">Todos</option>
{usuariosLista.map((u) => ( {usuariosLista.map(([id, nombre]) => (
<option key={u} value={u}> <option key={id} value={id}>
{u} {nombre}
</option> </option>
))} ))}
</select> </select>
@@ -136,7 +139,9 @@ export function AuditoriaLog() {
<td className="whitespace-nowrap px-3 py-2 text-[#374151] dark:text-[#CBD5E1]"> <td className="whitespace-nowrap px-3 py-2 text-[#374151] dark:text-[#CBD5E1]">
{new Date(item.createdAt).toLocaleString('es-MX')} {new Date(item.createdAt).toLocaleString('es-MX')}
</td> </td>
<td className="px-3 py-2 text-[#374151] dark:text-[#CBD5E1]">{item.usuarioId}</td> <td className="whitespace-nowrap px-3 py-2 text-[#374151] dark:text-[#CBD5E1]" title={item.usuarioId}>
{item.usuarioNombre ?? item.usuarioId}
</td>
<td className="px-3 py-2 text-[#374151] dark:text-[#CBD5E1]">{item.accion}</td> <td className="px-3 py-2 text-[#374151] dark:text-[#CBD5E1]">{item.accion}</td>
<td className="px-3 py-2 text-[#374151] dark:text-[#CBD5E1]">{item.entidad}</td> <td className="px-3 py-2 text-[#374151] dark:text-[#CBD5E1]">{item.entidad}</td>
<td className="max-w-[200px] truncate px-3 py-2 text-[#64748B] dark:text-[#94A3B8]">{item.detalle ?? '—'}</td> <td className="max-w-[200px] truncate px-3 py-2 text-[#64748B] dark:text-[#94A3B8]">{item.detalle ?? '—'}</td>

View File

@@ -170,6 +170,8 @@ export async function reindexarConocimiento(): Promise<{
export interface AuditoriaItem { export interface AuditoriaItem {
id: string id: string
usuarioId: string usuarioId: string
/** Nombre del usuario que hizo la acción (el API lo resuelve) */
usuarioNombre?: string
accion: string accion: string
entidad: string entidad: string
entidadId: string entidadId: string