Entrada 12: R07

Bitácora de Sesión

Fecha: 10/06/2026

Inicio: [17:00] | Fin: [19:30] || Total: [2 hora 30 minutos]

Presente: Matías Benavides Sandoval

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

¿QUÉ HICIMOS HOY?

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Se implementó R07 (visor de bitácora de eventos) completo: stored procedure, controller, rutas y frontend.

Se creó sp_GetBitacora como SP de solo lectura (sin transacción) que acepta filtros opcionales (@inIdUsuario, @inIdTipoEvento, @inFechaDesde, @inFechaHasta) y paginación (@inPageNumber, @inPageSize). Retorna dos result sets: la data paginada con joins a TipoEvento y Username de Usuario, y el count total de filas que cumplen los filtros.

Se creó bitacoraController.ts con una única función getBitacora que parsea los query params, llama al SP, y retorna los dos result sets. Se registraron las rutas en bitacora.ts bajo /api/bitacora (GET / con filtros, GET /tipos-evento para el dropdown).

Se creó la página bitacora.html con el mismo layout dark theme del proyecto. El formulario de filtros tiene 4 campos: usuario (select dinámico), tipo de evento (select dinámico), fecha desde, fecha hasta. La tabla muestra 10 filas por página con columnas: Fecha, Usuario, IP, Evento, Descripción. Se agregó paginación con botones Anterior/Siguiente.

Se agregó el menú "Bitácora" al sidebar de admin (solo visible cuando tipo='1').

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

PROBLEMAS DETECTADOS Y CÓMO SE RESOLVIERON

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Problema: el frontend no mostraba el total de registros para calcular páginas.

Causa: sp_GetBitacora solo devolvía la data, no el count total.

Solución: se modificó el SP para que haga un segundo SELECT con COUNT(*). El controller captura ambos result sets y los retorna como { data, total }. El frontend usa Math.ceil(total / pageSize).

Problema: los SPs de solo lectura tenían SET XACT_ABORT ON, que causaba Msg 3930.

Causa: SET XACT_ABORT ON es innecesario en SPs que no escriben nada.

Solución: se quitó SET XACT_ABORT ON de sp_GetError, sp_GetEmpleados y sp_GetEmpleadoById.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

AVANCE DEL CÓDIGO

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

import { Request, Response } from 'express';
import { getPool, sql } from '../db/connection';
import type { IRecordSet } from 'mssql';

type BitacoraRow = {
    id: number;
    idTipoEvento: number;
    TipoEvento: string;
    idUsuario: number | null;
    Username: string | null;
    Descripcion: string;
    PostTime: string;
    IpPostIn: string;
};

export async function getTiposEvento(_req: Request, res: Response): Promise<void> {
    try {
        const pool = await getPool();
        const result = await pool
            .request()
            .execute('sp_GetTiposEvento');

        res.status(200).json({
            success: true,
            outResultCode: 0,
            data: result.recordset,
        });
    } catch (error) {
        console.error('Error en getTiposEvento:', error);
        res.status(500).json({
            success: false,
            outResultCode: 50008,
            message: 'Error interno del servidor',
        });
    }
}

export async function getBitacora(req: Request, res: Response): Promise<void> {
    try {
        const pool = await getPool();

        const result = await pool
            .request()
            .input('inIdTipoEvento', sql.Int, req.query.idTipoEvento ? Number(req.query.idTipoEvento) : null)
            .input('inIdUsuario', sql.Int, req.query.idUsuario ? Number(req.query.idUsuario) : null)
            .input('inFechaDesde', sql.DateTime, req.query.fechaDesde ? new Date(String(req.query.fechaDesde)) : null)
            .input('inFechaHasta', sql.DateTime, req.query.fechaHasta ? new Date(String(req.query.fechaHasta)) : null)
            .input('inIpPostIn', sql.VarChar(64), req.query.ip ? String(req.query.ip).trim() : null)
            .input('inPageSize', sql.Int, parseInt(String(req.query.pageSize)) || 50)
            .input('inPageNumber', sql.Int, parseInt(String(req.query.page)) || 1)
            .output('outResultCode', sql.Int)
            .execute('sp_GetBitacora');

        const recordsets = result.recordsets as IRecordSet<any>[];
        const outResultCode: number = result.output.outResultCode;
        const data = recordsets[0] as BitacoraRow[];
        const total = recordsets[1]?.[0]?.Total ?? 0;

        if (outResultCode !== 0) {
            res.status(500).json({
                success: false,
                outResultCode,
                message: 'Error al consultar la bitacora',
            });
            return;
        }

        res.status(200).json({
            success: true,
            outResultCode: 0,
            data,
            total,
        });
    } catch (error) {
        console.error('Error en getBitacora:', error);
        res.status(500).json({
            success: false,
            outResultCode: 50008,
            message: 'Error interno del servidor',
        });
    }
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

MORALEJAS / BUENAS PRÁCTICAS

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un SP de consulta con paginación siempre debe retornar dos result sets: la data y el count total. Sin el count, el frontend no puede calcular el total de páginas.

Los SPs de solo lectura no necesitan SET XACT_ABORT ON ni BEGIN TRANSACTION. Solo complican el código y pueden causar bugs como Msg 3930.

El sidebar del admin debe ser condicional (tipo='1') para que los empleados impersonados no vean menús de administrador.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

PRÓXIMA SESIÓN: ¿QUÉ SIGUE?

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Tengo ganas de refactorizar buena parte del codigo, ya que hay mucho obsoleto que no se usa en lo mas minimo, asi que antes de cualquier cosa iria eso

Comentarios