From cd7595832f3e7033deaf44be792b0c2373d09bb2 Mon Sep 17 00:00:00 2001 From: Soporte ZH Consultores Date: Thu, 4 Jun 2026 20:30:46 -0500 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Registro=20de=20transacciones=20?= =?UTF-8?q?=E2=80=94=20log=20de=20auditor=C3=ADa=20de=20todo=20el=20sistem?= =?UTF-8?q?a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- public/admin/api.php | 52 ++++++++++ public/escrituras/api.php | 14 ++- public/tramites/api.php | 38 ++++++- sql/010_audit_log.sql | 26 +++++ src/AuditLog.php | 58 +++++++++++ src/Auth.php | 8 ++ views/admin.html | 212 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 403 insertions(+), 5 deletions(-) create mode 100644 sql/010_audit_log.sql create mode 100644 src/AuditLog.php diff --git a/public/admin/api.php b/public/admin/api.php index 6f21cb5..98c3034 100644 --- a/public/admin/api.php +++ b/public/admin/api.php @@ -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) { diff --git a/public/escrituras/api.php b/public/escrituras/api.php index a42c2b9..70d6125 100644 --- a/public/escrituras/api.php +++ b/public/escrituras/api.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/../../src/Auth.php'; require_once __DIR__ . '/../../src/Db.php'; +require_once __DIR__ . '/../../src/AuditLog.php'; if (($_GET['action'] ?? '') === 'logout') { header('Location: /auth/logout.php'); @@ -73,7 +74,9 @@ try { $row = $pdo->prepare('SELECT * FROM escrituras_tramites WHERE id = :id'); $row->execute([':id' => $id]); - ok($row->fetch()); + $r = $row->fetch(); + AuditLog::log('escrituras', 'create', 'escritura', (string)$id, $r['tramite'] ?? ''); + ok($r); } if ($method === 'PUT' && $action === 'update') { @@ -148,13 +151,20 @@ try { $row = $pdo->prepare('SELECT * FROM escrituras_tramites WHERE id = :id'); $row->execute([':id' => $id]); - ok($row->fetch()); + $r = $row->fetch(); + AuditLog::log('escrituras', 'update', 'escritura', (string)$id, $r['tramite'] ?? ''); + ok($r); } if ($method === 'DELETE' && $action === 'delete') { $id = (int) ($_GET['id'] ?? 0); if (!$id) err('ID requerido'); + // Fetch name before delete for the log + $nameStmt = $pdo->prepare('SELECT tramite FROM escrituras_tramites WHERE id = :id'); + $nameStmt->execute([':id' => $id]); + $tramiteName = (string)($nameStmt->fetchColumn() ?: ''); $pdo->prepare('DELETE FROM escrituras_tramites WHERE id = :id')->execute([':id' => $id]); + AuditLog::log('escrituras', 'delete', 'escritura', (string)$id, $tramiteName); ok(['deleted' => $id]); } diff --git a/public/tramites/api.php b/public/tramites/api.php index ee95b1b..1cc527a 100644 --- a/public/tramites/api.php +++ b/public/tramites/api.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/../../src/Auth.php'; require_once __DIR__ . '/../../src/Db.php'; +require_once __DIR__ . '/../../src/AuditLog.php'; if (($_GET['action'] ?? '') === 'logout') { header('Location: /auth/logout.php'); @@ -227,6 +228,8 @@ try { if ($myId > 0 && !in_array($myId, $involIds, true)) $involIds[] = $myId; syncInvolucrados($pdo, $id, $involIds, $user['email']); + AuditLog::log('tramites', 'create', 'tramite_legal', $codigo, $codigo, ['departamento'=>$dept, 'cliente'=>$b['cliente']??'']); + $row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id'); $row->execute([':id' => $id]); ok(hydrate($pdo, $row->fetch())); @@ -290,6 +293,8 @@ try { syncInvolucrados($pdo, $id, array_map('intval', $b['involucrados']), $user['email']); } + AuditLog::log('tramites', 'update', 'tramite_legal', $before['codigo'], $before['codigo']); + $row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id'); $row->execute([':id' => $id]); ok(hydrate($pdo, $row->fetch())); @@ -303,14 +308,16 @@ try { if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403); - $cur = $pdo->prepare('SELECT departamento FROM tramites_legales WHERE id = :id'); + $cur = $pdo->prepare('SELECT departamento, codigo FROM tramites_legales WHERE id = :id'); $cur->execute([':id' => $id]); - $dept = $cur->fetchColumn(); - if (!$dept) err('Trámite no encontrado', 404); + $curRow = $cur->fetch(); + if (!$curRow) err('Trámite no encontrado', 404); + $dept = $curRow['departamento']; if (!in_array($val, STATUS_MAP[$dept], true)) err('Estatus inválido para el departamento'); $pdo->prepare('UPDATE tramites_legales SET estatus = :s, actualizado_por = :u WHERE id = :id') ->execute([':s' => $val, ':u' => $user['email'], ':id' => $id]); + AuditLog::log('tramites', 'set_status', 'tramite_legal', $curRow['codigo'], $curRow['codigo'], ['estatus'=>$val]); ok(['id' => $id, 'estatus' => $val]); } @@ -330,6 +337,11 @@ try { $pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id') ->execute([':u' => $user['email'], ':id' => $id]); + $codSel = $pdo->prepare('SELECT codigo FROM tramites_legales WHERE id = :id'); + $codSel->execute([':id' => $id]); + $codigo = (string)$codSel->fetchColumn(); + AuditLog::log('tramites', 'add_obs', 'tramite_legal', $codigo, $codigo, ['abogado'=>$abog, 'texto'=>$texto]); + $row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id'); $row->execute([':id' => $id]); ok(hydrate($pdo, $row->fetch())); @@ -352,6 +364,11 @@ try { $pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id') ->execute([':u' => $user['email'], ':id' => $id]); + $codSel = $pdo->prepare('SELECT codigo FROM tramites_legales WHERE id = :id'); + $codSel->execute([':id' => $id]); + $codigo = (string)$codSel->fetchColumn(); + AuditLog::log('tramites', 'add_hours', 'tramite_legal', $codigo, $codigo, ['abogado'=>$abog, 'hrs'=>$hrs]); + $row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id'); $row->execute([':id' => $id]); ok(hydrate($pdo, $row->fetch())); @@ -383,6 +400,11 @@ try { $pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id') ->execute([':u' => $user['email'], ':id' => $tramiteId]); + $codSel = $pdo->prepare('SELECT codigo FROM tramites_legales WHERE id = :id'); + $codSel->execute([':id' => $tramiteId]); + $codigo = (string)$codSel->fetchColumn(); + AuditLog::log('tramites', 'update_hours', 'hour', (string)$hourId, $codigo, ['abogado'=>$abog, 'hrs'=>$hrs]); + $r = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id'); $r->execute([':id' => $tramiteId]); ok(hydrate($pdo, $r->fetch())); @@ -405,6 +427,11 @@ try { $pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id') ->execute([':u' => $user['email'], ':id' => $tramiteId]); + $codSel = $pdo->prepare('SELECT codigo FROM tramites_legales WHERE id = :id'); + $codSel->execute([':id' => $tramiteId]); + $codigo = (string)$codSel->fetchColumn(); + AuditLog::log('tramites', 'delete_hours', 'hour', (string)$hourId, $codigo); + $r = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id'); $r->execute([':id' => $tramiteId]); ok(hydrate($pdo, $r->fetch())); @@ -418,7 +445,12 @@ try { if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403); + $codSel = $pdo->prepare('SELECT codigo FROM tramites_legales WHERE id = :id'); + $codSel->execute([':id' => $id]); + $codigo = (string)$codSel->fetchColumn(); + $pdo->prepare('DELETE FROM tramites_legales WHERE id = :id')->execute([':id' => $id]); + AuditLog::log('tramites', 'delete', 'tramite_legal', $codigo, $codigo); ok(['deleted' => $id]); } diff --git a/sql/010_audit_log.sql b/sql/010_audit_log.sql new file mode 100644 index 0000000..c385f53 --- /dev/null +++ b/sql/010_audit_log.sql @@ -0,0 +1,26 @@ +-- ============================================================ +-- Donna — Schema 010: audit_log para trazabilidad de transacciones +-- ============================================================ +-- Registra login, logout, create, update, delete y demás mutaciones +-- de todos los módulos (escrituras, tramites, admin, etc.) con su +-- usuario, fecha, módulo, acción y referencia a la entidad afectada. + +CREATE TABLE IF NOT EXISTS audit_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + fecha DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + user_email VARCHAR(255) NOT NULL DEFAULT '', + user_name VARCHAR(255) NOT NULL DEFAULT '', + modulo VARCHAR(50) NOT NULL, + accion VARCHAR(50) NOT NULL, + entity_type VARCHAR(50) NULL, + entity_id VARCHAR(50) NULL, + entity_label VARCHAR(255) NULL, + detalles TEXT NULL, + ip_addr VARCHAR(45) NULL, + + INDEX idx_fecha (fecha DESC), + INDEX idx_user (user_email), + INDEX idx_modulo (modulo), + INDEX idx_accion (accion), + INDEX idx_entity (entity_type, entity_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/AuditLog.php b/src/AuditLog.php new file mode 100644 index 0000000..c210a8b --- /dev/null +++ b/src/AuditLog.php @@ -0,0 +1,58 @@ +prepare( + 'INSERT INTO audit_log + (user_email, user_name, modulo, accion, entity_type, entity_id, entity_label, detalles, ip_addr) + VALUES + (:email, :name, :modulo, :accion, :etype, :eid, :elabel, :det, :ip)' + ); + $stmt->execute([ + ':email' => $email, + ':name' => $name, + ':modulo' => $modulo, + ':accion' => $accion, + ':etype' => $entityType, + ':eid' => $entityId === null ? null : (string)$entityId, + ':elabel' => $entityLabel, + ':det' => $detalles !== null ? json_encode($detalles, JSON_UNESCAPED_UNICODE) : null, + ':ip' => $_SERVER['REMOTE_ADDR'] ?? null, + ]); + } catch (Throwable $e) { + error_log('AuditLog failed: ' . $e->getMessage()); + } + } +} diff --git a/src/Auth.php b/src/Auth.php index 2333651..c45ca3a 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/Config.php'; require_once __DIR__ . '/Db.php'; +require_once __DIR__ . '/AuditLog.php'; final class Auth { /** Cache por request del row de users del usuario autenticado */ @@ -182,11 +183,18 @@ final class Auth { $_SESSION['user_name'] = $name ?: $email; $_SESSION['auth_time'] = time(); self::$userRowCache = null; // invalidate cache so next userRow() call re-fetches + + AuditLog::logAs($email, $name ?: $email, 'auth', 'login', 'user', null, $email); return true; } public static function logout(): void { self::startSession(); + $email = $_SESSION['user_email'] ?? ''; + $name = $_SESSION['user_name'] ?? ''; + if ($email !== '') { + AuditLog::logAs($email, $name, 'auth', 'logout', 'user', null, $email); + } $_SESSION = []; if (ini_get('session.use_cookies')) { $p = session_get_cookie_params(); diff --git a/views/admin.html b/views/admin.html index 775c83f..99fd006 100644 --- a/views/admin.html +++ b/views/admin.html @@ -137,6 +137,38 @@ tr.is-me td { background:#FFFBEB; font-weight:500; } .toast.error { border-left-color:#ef5350; } .empty { text-align:center; padding:40px; color:var(--gray-400); } + +/* TABS */ +.tabs-wrap { max-width:1180px; margin:18px auto 0; padding:0 24px; display:flex; gap:4px; border-bottom:1px solid var(--gray-200); } +.tab { padding:10px 18px; cursor:pointer; font-size:13px; font-weight:500; color:var(--gray-700); + border-bottom:3px solid transparent; transition:all .15s; } +.tab:hover { color:var(--navy); } +.tab.active { color:var(--navy); border-bottom-color:var(--gold); font-weight:600; } +.panel { display:none; } +.panel.active { display:block; } + +/* AUDIT LOG */ +.log-toolbar { max-width:1180px; margin:18px auto 12px; padding:0 24px; display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; } +.log-tbl { max-width:1180px; margin:0 auto 32px; padding:0 24px; } +.log-tbl table th, .log-tbl table td { white-space:normal; } +.log-fecha { font-family:ui-monospace,monospace; font-size:11.5px; color:var(--gray-700); white-space:nowrap; } +.log-user { font-weight:500; color:var(--gray-900); } +.log-user-email { font-size:10.5px; color:var(--gray-400); display:block; } +.log-mod { display:inline-block; padding:2px 9px; border-radius:12px; font-size:10.5px; font-weight:700; + text-transform:uppercase; letter-spacing:.05em; color:#fff; } +.mod-auth { background:#6c5b95; } .mod-escrituras { background:#1B3A6B; } +.mod-tramites { background:#9E7C2E; } .mod-admin { background:#722f37; } +.mod-default { background:#9B9A96; } +.log-accion { font-family:ui-monospace,monospace; font-size:11.5px; color:var(--navy); font-weight:600; } +.log-entity { font-size:12px; color:var(--gray-700); } +.log-entity small { color:var(--gray-400); font-size:10.5px; } +.log-detalles { font-size:11px; color:var(--gray-400); max-width:320px; word-break:break-word; + font-family:ui-monospace,monospace; } +.log-row-limit { padding:14px; text-align:center; font-size:11.5px; color:var(--gray-400); + background:var(--off); border-top:1px solid var(--gray-100); } +.tb-quick-log { font-size:11px; padding:6px 10px; background:var(--off); border:1px solid var(--gray-200); + border-radius:6px; color:var(--gray-700); transition:all .15s; } +.tb-quick-log:hover { background:var(--navy); color:#fff; border-color:var(--navy); } @@ -167,6 +199,13 @@ tr.is-me td { background:#FFFBEB; font-weight:500; } +
+
Usuarios
+
Registro de transacciones
+
+ +
+

Usuarios y permisos

Administra la lista unificada de usuarios del sistema. El campo Nombre completo es lo que aparece en el dropdown "Abogado Solicitante" (solo si el rol es Socio o Abogado). Los emails que empiezan con placeholder- son los nombres sembrados al inicio para que los dropdowns no salieran vacíos; reemplázalos con los emails reales conforme los abogados se logueen.

@@ -209,6 +248,72 @@ tr.is-me td { background:#FFFBEB; font-weight:500; }
+ + +
+
+

Registro de transacciones

+

Cada login, logout, creación, edición y eliminación queda registrada con el usuario que la ejecutó. Útil para auditoría y para entender quién hizo qué.

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + + +
+
+
+ +
+
+ + + + + + + + + + + + +
Fecha · HoraUsuarioMóduloAcciónEntidadDetalles
+ + +
+
+
+