feat(admin): Registro de transacciones — log de auditoría de todo el sistema

NUEVA INFRAESTRUCTURA
* SQL 010: tabla audit_log (id, fecha, user_email, user_name, modulo,
  accion, entity_type, entity_id, entity_label, detalles JSON, ip_addr)
  con índices por fecha/user/modulo/accion/entity.
* src/AuditLog.php: helper estático con dos métodos:
  - log() lee actor de $_SESSION (uso normal post-auth)
  - logAs() recibe email/name explícito (para login/logout y CLI)
  Falla silenciosamente si la DB no responde — no rompe la operación
  principal por un fallo de logging.

HOOKS EN MUTACIONES
* Auth.php: registra 'login' al completar OAuth y 'logout' antes de
  destruir la sesión.
* escrituras/api.php: registra create/update/delete con id+nombre.
* tramites/api.php: registra 8 mutaciones — create/update/delete del
  trámite, set_status (con nuevo estatus en detalles), add_obs,
  add_hours/update_hours/delete_hours (con abogado y horas en
  detalles), todos referenciados por codigo (ADM-001, etc.).
* admin/api.php: registra create_user/update_user/delete_user, con
  diff de cambios en update_user.
* GETs (list_*) no se loguean — serían ruido.

LECTOR
* admin/api.php nuevo endpoint GET ?action=list_log que devuelve
  entradas con filtros from/to/user/modulo/accion/search (LIKE)
  + catálogos para dropdowns. Límite 500 por query.

UI EN PANEL DE CONTROL
* admin.html ahora tiene tabs: "Usuarios" (existente) y "Registro de
  transacciones" (nuevo).
* Tab Registro: toolbar de filtros (rango fechas, usuario, módulo,
  acción, búsqueda libre, atajos Hoy/Semana/Mes/Todo) + tabla con
  Fecha·Hora · Usuario · Módulo (pill coloreado) · Acción · Entidad
  con label y tipo · Detalles JSON resumido.
This commit is contained in:
Soporte ZH Consultores
2026-06-04 20:30:46 -05:00
parent 2f2d20604b
commit cd7595832f
7 changed files with 403 additions and 5 deletions
+52
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../../src/Auth.php';
require_once __DIR__ . '/../../src/Db.php';
require_once __DIR__ . '/../../src/Lookups.php';
require_once __DIR__ . '/../../src/AuditLog.php';
Auth::requireSocio();
header('Content-Type: application/json; charset=utf-8');
@@ -72,6 +73,7 @@ try {
$row['id'] = (int)$row['id'];
$row['activo'] = (int)$row['activo'];
$row['orden'] = (int)$row['orden'];
AuditLog::log('admin', 'create_user', 'user', (string)$id, $email, ['role'=>$role, 'name'=>$name]);
ok($row);
}
@@ -133,6 +135,11 @@ try {
$row['id'] = (int)$row['id'];
$row['activo'] = (int)$row['activo'];
$row['orden'] = (int)$row['orden'];
$diff = [];
if ($existing['role'] !== $newRole) $diff['role'] = ['from'=>$existing['role'], 'to'=>$newRole];
if ((int)$existing['activo'] !== $newActivo) $diff['activo'] = ['from'=>(int)$existing['activo'], 'to'=>$newActivo];
if ($existing['email'] !== $newEmail) $diff['email'] = ['from'=>$existing['email'], 'to'=>$newEmail];
AuditLog::log('admin', 'update_user', 'user', (string)$id, $newEmail, $diff ?: null);
ok($row);
}
@@ -157,9 +164,54 @@ try {
// hours.autor_email) permanecen como referencia muerta — eso es lo deseado para
// preservar la trazabilidad de trámites antiguos.
$pdo->prepare('DELETE FROM users WHERE id = :id')->execute([':id' => $id]);
AuditLog::log('admin', 'delete_user', 'user', (string)$id, $u['email'], ['role'=>$u['role']]);
ok(['deleted' => $id, 'email' => $u['email']]);
}
if ($method === 'GET' && $action === 'list_log') {
$from = $_GET['from'] ?? '';
$to = $_GET['to'] ?? '';
$uEmail = trim((string)($_GET['user'] ?? ''));
$modulo = trim((string)($_GET['modulo'] ?? ''));
$accion = trim((string)($_GET['accion'] ?? ''));
$search = trim((string)($_GET['search'] ?? ''));
$limit = min(1000, max(50, (int)($_GET['limit'] ?? 500)));
$where = ['1=1'];
$params = [];
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) { $where[] = 'fecha >= :from'; $params[':from'] = $from . ' 00:00:00'; }
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) { $where[] = 'fecha <= :to'; $params[':to'] = $to . ' 23:59:59'; }
if ($uEmail !== '') { $where[] = 'user_email = :ue'; $params[':ue'] = $uEmail; }
if ($modulo !== '') { $where[] = 'modulo = :m'; $params[':m'] = $modulo; }
if ($accion !== '') { $where[] = 'accion = :a'; $params[':a'] = $accion; }
if ($search !== '') {
$where[] = '(user_name LIKE :s OR user_email LIKE :s OR entity_label LIKE :s OR detalles LIKE :s)';
$params[':s'] = '%' . $search . '%';
}
$sql = 'SELECT id, fecha, user_email, user_name, modulo, accion, entity_type, entity_id, entity_label, detalles, ip_addr
FROM audit_log
WHERE ' . implode(' AND ', $where) . '
ORDER BY fecha DESC LIMIT ' . $limit;
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
foreach ($rows as &$r) { $r['id'] = (int)$r['id']; }
// Catálogos para los dropdowns de filtro
$modulos = $pdo->query("SELECT DISTINCT modulo FROM audit_log ORDER BY modulo")->fetchAll(PDO::FETCH_COLUMN);
$acciones = $pdo->query("SELECT DISTINCT accion FROM audit_log ORDER BY accion")->fetchAll(PDO::FETCH_COLUMN);
$usuarios = $pdo->query("SELECT DISTINCT user_email, MAX(user_name) AS user_name
FROM audit_log WHERE user_email != ''
GROUP BY user_email ORDER BY user_email")->fetchAll();
ok([
'entries' => $rows,
'modulos' => $modulos,
'acciones' => $acciones,
'usuarios' => $usuarios,
]);
}
err('Acción no reconocida', 404);
} catch (PDOException $e) {