# 06 — Backend

> Guía de referencia del comportamiento del servidor: entrada, enrutamiento, controladores, modelos, validación, errores, sesión, transacciones y registro.

---

## 1. Entrada de peticiones

Toda petición HTTP entra por **`public/index.php`**, que solo define constantes y carga el bootstrap:

```php
define('BASE_PATH', __DIR__.'/../');   // raíz del proyecto, con barra final
define('BASE_FILES', BASE_PATH.'/files/');
define('INC_DIR', '');
define('DIR_LLAMADO', '');             // segmento de URL entre base_url y ?c=
define('DIR_INDEX', 'public');         // destino de las redirecciones de error
require BASE_PATH."core/AutoLoad.php";
```

No hay reescritura de URLs: `public/.htaccess` declara explícitamente `RewriteEngine Off`.

`URL::base_url()` (`core/Url.php`) construye la URL base a partir de `$_SERVER`.

---

## 2. Enrutamiento

**No hay tabla de rutas.** El despacho es reflexivo:

```php
$controller = $_GET["c"] ?? 'Home';
$class_controller = ucfirst($controller) . "Controller";
if (file_exists(BASE_PATH."app/controllers/$class_controller.php") && class_exists($class_controller)) {
    $app = new $class_controller;
    call_user_func(array($app, 'process'));
}
```

Y dentro de `Controller::process()`:

```php
$action = $_GET["a"] ?? $this->getActionDefault();
// … validaciones …
$this->{$action.'Action'}();
```

### Consecuencias

| Hecho | Implicación |
|---|---|
| El nombre de la clase se deriva de `$_GET['c']` con `ucfirst()` | `?c=metas` y `?c=Metas` resuelven ambos a `MetasController`. `?c=metasColaborador` **no** resuelve a `MetasColaboradorController` — el resto de mayúsculas debe coincidir |
| Si el controlador no existe → flash de error + redirección a `home` | Nunca se devuelve 404 |
| Si la acción no existe (`NO_ACTION`) → redirección a `DIR_INDEX` | Tampoco hay 404 |
| Un método `xxxAction()` **no declarado** en `loadAccessControl()` queda inaccesible | Es el mecanismo de "deny by default" |

`PermisosModel::hasAccess()` prueba dos candidatos de nombre de clase para tolerar nombres compuestos:
1. `$controller` tal cual, con `Controller` añadido si falta el sufijo.
2. `ucfirst($controller) . 'Controller'` (respaldo heredado).

---

## 3. Controladores

### Anatomía mínima

```php
<?php
class MiModuloController extends Controller
{
    public static $TITLE_NAME  = 'Mi Módulo';
    public static $MODULE_NAME = 'MiModulo';    // clave en la tabla permisos
    protected $ViewFolder      = 'mi_modulo';   // app/views/mi_modulo/
    // protected $MyLayout     = 'metronicPublic';  // solo si NO es backoffice
    // protected $ActionDefault = 'index';          // por defecto 'list'

    protected function loadAccessControl()
    {
        $this->AccessControl = array(
            'list'         => '@',
            'view'         => '@',
            'create'       => '@',
            'edit'         => '@',
            'remove'       => '@',
            'dataListAjax' => '@',
        );
    }

    protected function getListAjaxObject() { /* … */ }
}
```

### Niveles de `AccessControl`

| Valor | Significado |
|---|---|
| `'*'` | Abierto: **no requiere sesión ni permiso** |
| `'@'` | Requiere sesión activa **y** permiso en BD (o ser el usuario 1, o módulo/acción en las listas generales) |
| *(ausente)* | **Denegado para todos**, incluido el administrador |

### El patrón CRUD canónico

Tomado de `app/controllers/CompetenciasController.php` — **es el modelo a imitar**:

```php
public function createAction()
{
    $this->Model = CompetenciasModel::model();               // instancia estática compartida
    if (isset($_POST[get_class($this->Model)])) {            // ¿hay envío del formulario?
        if ($this->Model->save()) {                          // save() valida CSRF internamente
            UserFlash::setFlash('Success', 'Se creó la competencia correctamente.');
            ROUTER::redirect_to_action($this->Module, 'list');
        }
        UserFlash::setFlash('Error', 'Ocurrió un error al crear la competencia.');
        ROUTER::redirect_to_action($this->Module, 'create');
    }

    $parameters = $this->loadMetadata();
    $parameters['model']     = $this->Model;
    $parameters['csrfToken'] = Controller::generateCsrfToken();
    View::render_view($this->ViewFolder . '/create', $parameters);
}

public function editAction()
{
    $this->Model = new CompetenciasModel();
    if (isset($_POST[get_class($this->Model)])) {
        $this->Model->loadById($_POST[get_class($this->Model)]['Id']);   // hidrata antes de guardar
        if ($this->Model->save()) { /* … */ }
    }
    if (isset($_GET['Id'])) {
        $this->Model->loadById($_GET['Id']);
        $parameters = $this->loadMetadata();
        $parameters['model']     = $this->Model;
        $parameters['csrfToken'] = Controller::generateCsrfToken();
        View::render_view($this->ViewFolder . '/edit', $parameters);
        return;
    }
    UserFlash::setFlash('Error', 'Parámetros inválidos.');
    ROUTER::redirect_to_action($this->Module, 'list');
}
```

Puntos que **no** son opcionales:
1. `isset($_POST[get_class($this->Model)])` es la forma canónica de detectar el envío.
2. En `edit`, hay que **`loadById()` antes de `save()`** para que el modelo tenga un `Id` y haga UPDATE en lugar de INSERT.
3. Siempre `UserFlash::setFlash()` + `ROUTER::redirect_to_action()` tras una escritura (patrón POST-Redirect-GET).
4. `$parameters['csrfToken'] = Controller::generateCsrfToken()` en toda vista con formulario.

### Envolver `dataListAjax` en `try/catch`

Varios controladores lo hacen para que un fallo de consulta no rompa el JSON que espera DataTables:

```php
public function dataListAjaxAction()
{
    try {
        parent::dataListAjaxAction();
    } catch (Throwable $exception) {
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode(array(
            'draw' => (int)($_POST['draw'] ?? 0),
            'recordsTotal' => 0, 'recordsFiltered' => 0, 'data' => array(),
            'error' => 'No fue posible cargar … en este momento.'
        ));
    }
}
```

### Marcar el ítem de menú activo

```php
public function listAction()
{
    Menu::setActive('competencias');   // debe coincidir con "Nombre" en Menu.php
    parent::listAction();
}
```

---

## 4. Modelos

### Contrato

```php
class MiModuloModel extends Model
{
    protected static $TABLE_NAME = 'mi_modulo';
    protected static $VIEW_NAME  = 'vista_mi_modulo';   // o la propia tabla si no hay vista
    // protected static $LOG   = true;    // auditoría en log_modules
    // protected static $CACHE = true;    // caché de lecturas (nadie lo usa hoy)

    public static function getOptionsAttributes()
    {
        return array(
            array('Type' => 'AutoincrementId', 'Name' => 'Id'),
            array('Type' => 'text',     'Name' => 'Nombre', 'Title' => 'Nombre', 'Required' => true, 'MaxLength' => 150),
            array('Type' => 'select',   'Name' => 'IdCargo', 'Title' => 'Cargo', 'Table' => 'cargos'),
            array('Type' => 'checkbox', 'Name' => 'Estado'),
            array('Type' => 'RegistrationUser', 'Name' => 'UsuarioRegistro'),
            array('Type' => 'RegistrationDate', 'Name' => 'FechaRegistro'),
            array('Type' => 'ModificationUser', 'Name' => 'UsuarioModificacion'),
            array('Type' => 'ModificationDate', 'Name' => 'FechaModificacion'),
        );
    }

    public static function model($className = __CLASS__) { return parent::model($className); }
}
```

### Opciones de atributo reconocidas

Cualquier clave del array se asigna a la propiedad homónima de `Atributo` (con `ucfirst`). Las reconocidas por la clase base son:

| Opción | Tipo | Efecto |
|---|---|---|
| `Name` | string | Nombre de columna. **Obligatoria** |
| `Type` | string | Tipo de atributo; por defecto `text` |
| `Title` | string | Etiqueta visible. Si falta, se genera desde `Name` separando mayúsculas |
| `Required` | bool | Valida no vacío en `validateRequired()` |
| `MinLength` / `MaxLength` | int | `validateLength()` (por defecto 0 / 50) |
| `Table` | string | Tabla origen para `select` |
| `Fields` | array | Campos a mostrar del `select` (por defecto `['Nombre']`) |
| `Options` | array | Opciones literales |
| `TextHelp` | string | Texto de ayuda |
| `Label` | string | Clase de etiqueta (por defecto `inline-label`) |
| `CanEditOnCreate` / `CanEditOnUpdate` | bool | Si es `false`, la columna no entra en el INSERT/UPDATE |
| `TypeSql` | string | Tipo SQL sugerido (por defecto `varchar`) |

Los tipos automáticos `RegistrationUser`, `RegistrationDate`, `ModificationUser`, `ModificationDate` rellenan su valor solos: los dos primeros solo en creación, los dos últimos solo en edición.

### Los 26 tipos de atributo

`AutoincrementId`, `Checkbox`, `Consecutive`, `Date`, `Decimal`, `Document`, `Email`, `Encrypted`, `FechaHora`, `Hidden`, `HtmlEditor`, `Image`, `Integer`, `ModificationDate`, `ModificationUser`, `Money`, `Password`, `Radio`, `RegistrationDate`, `RegistrationUser`, `Select`, `Slider`, `Text`, `Textarea`, `Time`, `UniqueId`

> `Document.php`, `Image.php` y `Slider.php` empiezan con el comentario `//TODO` — su implementación puede estar incompleta.

### Cuándo usar cada método de escritura

| Situación | Método | Notas |
|---|---|---|
| Formulario POST completo | `$model->save()` | Lee `$_POST`, valida CSRF y tipos |
| Formulario POST con subconjunto de campos | `$model->save(array('Nombre','Estado'))` | Solo esos atributos |
| Inserción desde código | `$model->createFromParameters(array('Campo'=>$valor, …))` | **El más usado en módulos nuevos.** No lee `$_POST`, no valida CSRF |
| Actualización parcial por criterio | `MiModelo::editFromParameters($campos, $criteria, array())` | **Estático.** Aplica filtros de sesión salvo que pases `array()` |
| Actualización de un campo vía AJAX | `$model->saveAjax()` | Lee `$_POST[Clase]['attribute']` |
| Escritura sin leer `$_POST` | `$model->saveOnly('create'\|'update')` | Valida tipos pero no recibe datos |
| Borrado por Id | `$model->deleteById($id)` | Con `$LOG=true` carga el registro antes, para poder auditarlo |
| Borrado por criterio | `MiModelo::deleteByCriteria1($criteria, array(), $allMatches)` | `$allMatches=false` borra **solo la primera coincidencia** (`LIMIT 1`) |

> ⚠️ `deleteByCriteria()` con criterio vacío genera `WHERE 1='2'` como salvaguarda: no borra nada.

### Reglas de negocio en el modelo

El patrón dominante en los módulos nuevos: **métodos estáticos de validación que devuelven un array de resultado**.

```php
public static function validarSolicitud($colaboradorId, $monto, $fecha, $tieneSoporte, $motivo)
{
    $errores = array();
    // … comprobaciones …
    return array('ok' => empty($errores), 'errores' => $errores, 'politica' => $politica);
}
```

Variantes del contrato encontradas en el código (no están unificadas):
- `array('ok' => bool, 'errores' => array())` — adelantos
- `array('ok' => bool, 'errors' => array(), …)` — metas *(nótese `errors` en inglés)*
- `array(bool, string)` desestructurado con `list()` — vacaciones
- `true | string` — `RecruitmentApplicationsModel::validarContratacion()`

Al añadir una validación, **imita la del módulo en el que trabajas**, no inventes un cuarto contrato.

### Hooks disponibles

```php
public function beforeCreate($attributes = array('*'))
{
    $ok = parent::beforeCreate($attributes);   // ← imprescindible
    if (!$ok) return false;
    return $this->miValidacionAdicional();
}
public function beforeUpdate($attributes = array('*')) { /* idem */ }
```

Ejemplo real: `ColaboradoresModel` los usa para `normalizaCamposUnicos()` y `validaMayorDeEdad()`.

---

## 5. Servicios

No hay una capa de servicio formal. Cuando la lógica cruza varios modelos, el proyecto crea una clase **sin herencia** con métodos estáticos:

| Clase | Ubicación | Responsabilidad |
|---|---|---|
| `TalentScoreService` | `app/models/` | Cálculo consolidado de talento y 9-Box |
| `NominaCalculoModel` | `app/models/` | Motor de liquidación de nómina |
| `ContratacionDesdeOfertaService` | `app/models/` | Convertir una oferta aceptada en colaborador |
| `ScoringCandidatosService` | `app/models/` | Puntuación automática de candidatos |
| `GeneradorPlanesDesarrolloService` | `app/models/` | Sugerir acciones de desarrollo |
| `CalculadorProgresoPlanService` | `app/models/` | Progreso de un plan de desarrollo |
| `PlanesDesarrolloSchemaHelper` | `app/models/` | Tolerancia a variaciones de esquema |
| `AsistenteIAService` + `SQLGenerator` + `DatabaseContext` + `ResponseFormatter` | `services/asistente_ia/` | Asistente IA |
| `OllamaProvider` (`LLMInterface`) | `services/llm/` | Cliente HTTP del LLM |
| `PromptBuilder` | `services/prompt/` | Construcción de prompts |

> Las clases de `services/` **sí usan interfaces y constructor con inyección** (`AsistenteIAService::__construct(array $config)`); es el único rincón del código con ese estilo. Las de `app/models/` son estáticas.

**Regla:** un servicio nuevo va en `services/` si es infraestructura reutilizable; en `app/models/` si es lógica de dominio de RR. HH. En ambos casos, hay que añadir la ruta al array `$autoloadSearchPaths` de `core/AutoLoad.php` si es un subdirectorio nuevo.

---

## 6. Validaciones

Hay **tres niveles** de validación, en este orden:

### 6.1 Nivel atributo (automático)

`Atributo::receiveData()` → `validateRequired()` + `validateData()` (implementación por tipo) + opcionalmente `validateLength()`.

Los fallos se acumulan como flashes: `'El campo {Title} es requerido'`, `'El campo {Title} con valor {Value}, no cumple con los requerimientos de longitud'`.

Si `Config::$debug` está activo, además se emite `<script>console.log("Campo no valido: …")</script>` **en medio del HTML**.

### 6.2 Nivel modelo (manual)

Hooks `beforeCreate`/`beforeUpdate` y métodos estáticos de validación de negocio.

### 6.3 Nivel controlador (manual)

Saneamiento de la entrada antes de construir criterios. El patrón que se ha ido introduciendo en los módulos nuevos:

```php
$filtros = array(
    'EstadoSolicitud' => $this->cleanText($_GET['EstadoSolicitud'] ?? ''),
    'BeneficioId'     => (int)($_GET['BeneficioId'] ?? 0),
    'FechaDesde'      => $this->cleanText($_GET['FechaDesde'] ?? ''),
);
```

`cleanText()` es un método **privado duplicado literalmente en 21 controladores**: `AdelantoPoliticasController`, `AdelantosNominaController`, `AdelantosNominaPortalController`, `BeneficiosAsignacionesController`, `BeneficiosController`, `BeneficiosPortalController`, `BeneficiosSolicitudesController`, `CapacitacionController`, `CapacitacionInscripcionesController`, `CapacitacionLeccionesController`, `CapacitacionPortalController`, `CentroAyudaController`, `ComunicacionInternaController`, `LineaEticaController`, `LineaEticaPublicaController`, `MensajesInternosController`, `PlanesCarreraController`, `PotencialesController`, `ReconocimientosController`, `SucesionController`, `TicketsController`. **No está en el core.** Ver [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md) § deuda técnica.

> 🔴 **Esta es la única defensa contra inyección SQL en las cláusulas WHERE**, porque `MysqlPDO::criteriaToSql()` no escapa. Todo valor que llegue de `$_GET`/`$_POST` a un criterio **debe** pasar por un cast o una limpieza explícita. `tests/MutationSecurityRegressionTest::testGeneralAjaxLookupConstrictsDynamicQueryInputs` cubre este contrato para `AjaxController`.

---

## 7. Manejo de errores

| Mecanismo | Dónde | Comportamiento |
|---|---|---|
| `try/catch` global | `core/AutoLoad.php` | Captura `Error` y `Exception` del despacho y hace **`echo " {$exception}"`** — imprime la traza completa en la respuesta |
| `ErrorHandler.php` | Solo si `APP_DEBUG=true` | Registra `handler()` y `shut()` para errores y fatales |
| `error_reporting(E_ALL & ~E_NOTICE)` | Si `APP_DEBUG=false` | Oculta avisos |
| `UserFlash` | Toda la aplicación | Mensajes al usuario, mostrados por `app/layouts/flashes/{layout}.php` |
| `LogsConsole` | Con `$debug` | Vuelca cada SQL y los avisos al `console.log` del navegador vía `app/layouts/logs/{layout}.php` |
| `Logger` | Manual | Ficheros con rotación en `logs/` |
| `app/views/error/generic.php` | Vista de error genérica | |

### 🔴 Riesgo

El `catch` global de `core/AutoLoad.php` **no depende de `APP_DEBUG`**:

```php
} catch (Error $exception) {
    echo " {$exception}";
} catch (Exception $exception) {
    echo " {$exception}";
}
```

En producción, cualquier excepción no capturada muestra al usuario la traza completa con rutas absolutas del servidor. Ver `18_KNOWN_ISSUES.md`.

### Estados HTTP

El sistema **casi nunca** devuelve códigos HTTP de error. Los fallos de autorización redirigen con 302. Las excepciones son:
- `ApiController::quickSearch` → `http_response_code(500)`
- `AsistenteIAController` → 403 / 405 / 422 / 500 en JSON
- `AjaxController::sendAjaxError()` → 403
- `AlertasEmailController::guardCronAccess()` → `HTTP/1.1 403` / `503`
- `ElFinderController` → `503` si falta la librería

---

## 8. Sesiones

### Estructura de `$_SESSION`

Todo cuelga de la clave `Config::$appId` (por defecto `'Kuorum'`, configurable con `APP_ID`):

```php
$_SESSION['Kuorum'] = [
    'User'          => [ /* fila de usuarios o de colaboradores, sin Contrasena, con 'Type' */ ],
    'UserType'      => [ 'Id', 'Name', 'Code', 'Home', 'Grupo', 'Descripcion', 'Estado' ],
    'Permissions'   => [ 'ModuleName' => ['create'=>1,'view'=>1,…,'Tabs'=>[…]] , … ],
    'Menu'          => [ /* árbol de menú ya filtrado por permisos */ ],
    'Shortcuts'     => [],
    'MenuActive'    => 'competencias',
    'MenuOpen'      => true,
    'ShowPopup'     => true,
    'filtersSesion' => [ 'Estado' => 1, 'Periodo' => 29 ],
    'csrf_token'    => '…64 hex…',
    'ColaboradorPublic' => true,     // solo en sesiones del portal
    'DemoMode'      => true,         // solo en modo demo
    'microsoft_oauth_state' => '…',  // transitorio durante el flujo OAuth
    'rate_limiter'  => [ … ],        // RateLimiter
];
```

También existe una rama paralela **`$_SESSION['public'][APP_ID]['log']`** y `['log_url']`, que usa `Model::log()` para enlazar `log_modules` con `log_acceso` y `log_urls`.

### Endurecimiento

Configurado en `core/AutoLoad.php` **antes** de `session_start()`:
- `session.use_strict_mode = 1`
- `session.cookie_httponly = 1`
- `session.cookie_secure` = 1 si HTTPS
- `samesite = Strict`

`Controller::regenerateSessionOnAuthentication()` hace `session_regenerate_id(true)` y rota el token CSRF; se invoca en ambos logins.

`logout` hace `unset($_SESSION[APP_ID])` — **no destruye la sesión PHP completa** (`session_destroy()`), solo la rama de la aplicación.

### Heartbeat

`public/check_session.php` devuelve `1` si hay sesión. `core/check_sessions.js` lo consulta periódicamente y, si falla, alerta y redirige a `index.php`.

---

## 9. Acceso a base de datos y transacciones

### Conexiones

```php
$conn = DB::getConnection('klee');   // devuelve MysqlPDO (extiende PDO)
```

Conexión perezosa, cacheada en `Controller::$DB_CONNECTIONS['klee']['instance']`. Configurada desde `.env` por `Config::syncConfigFromEnv()`.

### Transacciones

**No hay abstracción de transacciones.** Se usa PDO directamente. `grep -rn "beginTransaction"` sobre `app/`, `core/` y `services/` devuelve **una sola ocurrencia** en todo el proyecto:

```php
// ColaboradoresModel::deleteCascade()
$conn = DB::getConnection(static::$CONNECTION_NAME);
$conn->beginTransaction();
try {
    // … DELETEs en ~15 tablas …
    $conn->commit();
} catch (Throwable $e) {
    $conn->rollBack();
    return false;
}
```

> 🔴 **Ningún flujo de negocio multi-tabla usa transacciones.** Aprobar unas vacaciones (cambia estado + descuenta saldo), aprobar un beneficio (cambia estado + consume cupo y presupuesto), o recalcular una nómina (escribe liquidación + detalle) pueden quedar a medias. Ver `18_KNOWN_ISSUES.md`.

Si añades un flujo con varias escrituras dependientes, envuélvelo:

```php
$conn = DB::getConnection('klee');
$conn->beginTransaction();
try {
    // escrituras
    $conn->commit();
} catch (Throwable $e) {
    $conn->rollBack();
    Logger::error('…', array('error' => $e->getMessage()));
    return false;
}
```

### Consultas crudas

```php
MiModelo::queryAllSql("SELECT …");        // estática, sobre la conexión del modelo
DB::getConnection('klee')->querySql($sql); // una fila
DB::getConnection('klee')->sql($sql);      // ejecuta sin devolver filas
```

Con `Controller::$debug` activo, `MysqlPDO` vuelca cada SQL a `LogsConsole`.

---

## 10. Caché

`core/Cache.php` — caché en ficheros con TTL y espacios de nombres.

```php
Cache::init();
$clave  = array('MiModelo', 'getAll', $criteria);   // se serializa y hashea
Cache::set($clave, $valor, 300);
Cache::setWithNamespace($clave, $valor, 300, 'MiModelo');
$valor  = Cache::get($clave);        // null si no hay o expiró
Cache::delete($clave);
Cache::deleteNamespace('MiModelo');  // invalida todo lo del modelo
Cache::flush();
```

Usos reales:
- `Model` cuando `$CACHE = true` — **ningún modelo lo activa hoy**.
- `AsistenteIAService` — cachea resultados de SQL por `sha1($sql)`, namespace `AsistenteIA`.

---

## 11. Rate limiting

`core/RateLimiter.php`, almacenado **en sesión** (`$_SESSION['rate_limiter']`).

```php
$key   = 'login_admin:' . hash('sha256', strtolower($user));
$limit = RateLimiter::check($key, 10, 900);   // 10 intentos / 900 s
if (empty($limit['allowed'])) { /* rechazar */ }
RateLimiter::reset($key);                     // tras un login correcto
```

Usos reales: `LoginController::validateAction()` y `PublicController::validateAction()`, ambos con 10 intentos / 15 minutos.

> ⚠️ Al vivir en la sesión, el atacante puede saltárselo descartando la cookie en cada intento. **No es una defensa efectiva contra fuerza bruta distribuida.**

---

## 12. Logging

Cuatro subsistemas independientes:

| Subsistema | Clase | Destino | Activación |
|---|---|---|---|
| Log de acceso | `LogAccionesModel::saveAccess()` | tabla `log_acceso` | `Config::$LOG_ACTIONS` |
| Log de URL | framework | tabla `log_urls` | `Config::$LOG_ACTIONS` |
| Log de módulos (diff) | `Model::log()` | tabla `log_modules` | `Config::$LOG_MODULES` **y** `$LOG=true` en el modelo |
| Log estructurado | `Logger` / `LoggerManager` / `LoggerConfig` | ficheros en `logs/` con rotación | Siempre |
| Consola del navegador | `LogsConsole` | `console.log` del cliente | `Config::$debug` |
| Bitácora de dominio | `BitacoraAuditoriaModel::registrar()` | tabla `bitacora_auditoria` | Manual |

### API de `Logger`

```php
Logger::debug($mensaje, $contexto);
Logger::info($mensaje, $contexto);
Logger::warning($mensaje, $contexto);
Logger::error($mensaje, $contexto);
Logger::critical($mensaje, $contexto);
Logger::setMinLevel(Logger::LEVEL_WARNING);
Logger::tail(100);
Logger::getByLevel('ERROR', 50);
Logger::getByUser($userId, 50);
```

Enriquece el contexto con usuario, IP y URL. Rota el fichero por tamaño (`rotateIfNeeded()`).

### API de `LoggerManager`

```php
LoggerManager::logLogin($exito, $usuario, $contexto);
LoggerManager::logAction($accion, $claseModelo, $datos, $recordId, $userId);
LoggerManager::logError($mensaje, $contexto, 'ERROR');
LoggerManager::logAccessDenied($razon, $contexto);
LoggerManager::log($nivel, $mensaje, $contexto, $tipoEvento);
LoggerManager::getStats();
```

`LoggerConfig::getBackends($tipoEvento)` decide a qué destinos va cada tipo. `setProductionMode()` / `setDevelopmentMode()` cambian el perfil.

---

## 13. Colas de assets

Un controlador puede inyectar CSS/JS específico en la página:

```php
QueueCss::pushBefore($href, $opciones);   // antes de los bundles de Metronic
QueueCss::pushAfter($href, $opciones);    // después
QueueScripts::pushBefore($src, $opciones);
QueueScripts::pushAfter($src, $opciones);
```

El layout los vuelca con `QueueCss::getBefore()` / `getAfter()` y `QueueScripts::getBefore()` / `getAfter()`. Se almacenan en sesión con prefijos `QueueCssBefore`/`QueueCssAfter` y se vacían al leerse.

---

## 14. Helpers disponibles

`core/helpers/` — todos estáticos, sin namespace:

| Helper | Uso |
|---|---|
| `DateHelper` | Formateo y cálculo de fechas |
| `TimeHelper` | Horas y duraciones |
| `TextHelper` | Manipulación de texto |
| `NumberHelper` | Formateo numérico |
| `MoneyHelper` | Formateo monetario |
| `PassHelper` | `encode($pass)` / `verify($pass, $hash)` — 🔴 **MD5** |
| `QuitarTildesHelper` | Normalización de acentos |
| `KHtml` | Generación de HTML |
| `KleePicture`, `PHPImage` | Manipulación de imágenes (firmas, fotos) |
| `DebugHelper` | Volcado de depuración |

**Antes de escribir una utilidad nueva, comprueba si ya existe aquí.**

---

## 15. Patrones que deben mantenerse

| # | Patrón | Por qué |
|---|---|---|
| 1 | Declarar **toda** acción nueva en `loadAccessControl()` | Si no, es inaccesible |
| 2 | POST-Redirect-GET con `UserFlash` tras cada escritura | Es el flujo que esperan los layouts |
| 3 | `loadById()` antes de `save()` en la edición | Si no, hace INSERT |
| 4 | `$parameters = $this->loadMetadata()` antes de renderizar | Sin él, el layout no tiene menú, marca ni permisos |
| 5 | `Controller::generateCsrfToken()` en toda vista con formulario | |
| 6 | Nombres de input `Modelo[Campo]` | Es lo que lee `Atributo::receiveData()` |
| 7 | Constantes `ESTADO_*` para los estados, nunca literales sueltos | |
| 8 | Tabla `*_historial` / `*_bitacora` para todo flujo de estados | Patrón consolidado en 7 módulos |
| 9 | Métodos estáticos `validar*()` que devuelven `['ok'=>…, 'errores'=>…]` | |
| 10 | Castear o limpiar toda entrada antes de meterla en un criterio | Única defensa contra SQLi |
| 11 | Pasar `array()` como tercer argumento cuando no quieras filtros de sesión | Evita resultados vacíos inesperados |
| 12 | `Menu::setActive('nombre')` al principio de la acción de listado | Marca el ítem del menú |

---

## Documentos relacionados
- [02_ARCHITECTURE.md](02_ARCHITECTURE.md)
- [07_FRONTEND.md](07_FRONTEND.md)
- [09_AUTHENTICATION_AUTHORIZATION.md](09_AUTHENTICATION_AUTHORIZATION.md)
- [14_DEVELOPMENT_GUIDELINES.md](14_DEVELOPMENT_GUIDELINES.md)
