# 15 — Despliegue y Operación

> Lo que puede afirmarse a partir del código y de la configuración presente en el repositorio.
> **No hay Dockerfile, docker-compose, CI/CD, ni scripts de despliegue.** Todo lo que sigue es manual.

---

## 1. Requisitos

### Verificados en el código

| Requisito | Evidencia |
|---|---|
| **PHP** | El entorno de desarrollo usa 8.5.4. El código emplea `??`, `?->` no, `random_bytes`, `hash_equals`, `password_hash`, `session_set_cookie_params` con array (PHP ≥ 7.3), tipado de propiedades (`protected array $var_dump` en `SincronizacionController`, PHP ≥ 7.4) y `private const` (PHP ≥ 7.1). **Mínimo real: PHP 7.4.** Probado en 8.5 |
| **Extensiones obligatorias** | Comprobadas en `core/AutoLoad.php`: `gd`, `mbstring`, `pdo`, `pdo_mysql`, `openssl`. **Solo se registra un aviso en `error_log` si faltan; la aplicación no aborta** |
| **Extensiones adicionales usadas** | `curl` (OAuth de Microsoft, Ollama), `json`, `session`, `fileinfo` (elFinder) |
| **MySQL / MariaDB** | El esquema usa `utf8mb4_0900_ai_ci`, colación **introducida en MySQL 8.0**. Con MariaDB habría que ajustar la colación |
| **Composer** | 2.x. `composer.json` requiere `minimum-stability: stable` |
| **Servidor web** | Apache: `.htaccess` con `mod_authz_core`, `php_value`, `RewriteEngine` |
| **Locale del sistema** | `es_CO.utf8` (`setlocale` en `core/AutoLoad.php`) |
| **Zona horaria** | `America/Bogota` (fijada en código) |

### `[NO DETERMINADO EN EL CÓDIGO]`
- Versión mínima de PHP soportada oficialmente.
- Requisitos de memoria, CPU y disco.
- Si se soporta nginx + PHP-FPM (el código depende de `.htaccess`, así que requeriría traducir las reglas).
- Estrategia de copia de seguridad.
- Entornos existentes (desarrollo, preproducción, producción) y sus URLs.

---

## 2. Instalación desde cero

```bash
# 1. Código
git clone <repositorio> kuorum
cd kuorum

# 2. Dependencias
composer install --no-dev --optimize-autoloader     # en producción
# composer install                                   # en desarrollo (incluye PHPUnit y Faker)

# 3. Configuración
cp .env.example .env
# Editar .env: DB_*, MAIL_*, APP_*, CRON_TOKEN
# APP_DEBUG=false · DEMO_MODE_ENABLED=false · DIRECTORIO_ACTIVO=false

# 4. Base de datos
mysql -u root -p -e "CREATE DATABASE kuorum CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;"
mysql -u root -p -e "CREATE USER 'kuorum'@'localhost' IDENTIFIED BY '<contraseña>';"
mysql -u root -p -e "GRANT ALL PRIVILEGES ON kuorum.* TO 'kuorum'@'localhost';"

# 5. Esquema
php bin/migrate validate     # revisión estática, sin conectar
php bin/migrate status
php bin/migrate up

# 6. Datos iniciales
php bin/seed base            # producción: núcleo + catálogos + nómina base
# php bin/seed modulos       # preproducción: + datos de los módulos
# php bin/seed completo      # demostración: + escenarios de empresa

# 7. Verificación de datos
php bin/seed --verify-demo

# 8. Permisos del sistema de ficheros
chown -R www-data:www-data files storage logs
chmod -R 775 files storage logs

# 9. Locale del sistema (Debian/Ubuntu)
sudo sed -i 's/# es_CO.UTF-8/es_CO.UTF-8/' /etc/locale.gen && sudo locale-gen

# 10. Servidor web (ver §3)
```

---

## 3. Configuración del servidor web

### Recomendada — `DocumentRoot` en `public/`

```apache
<VirtualHost *:443>
    ServerName kuorum.ejemplo.com
    DocumentRoot /var/www/kuorum/public

    SSLEngine on
    SSLCertificateFile    /ruta/cert.pem
    SSLCertificateKeyFile /ruta/key.pem

    <Directory /var/www/kuorum/public>
        AllowOverride All
        Require all granted
        Options -Indexes
    </Directory>

    # files/ está FUERA de public/ pero debe ser accesible:
    # el layout referencia el favicon como URL::base_url() . '/../files/…'
    Alias /files /var/www/kuorum/files
    <Directory /var/www/kuorum/files>
        Require all granted
        Options -Indexes -ExecCGI
        php_flag engine off              # no ejecutar PHP subido por usuarios
        <FilesMatch "\.(php|phtml|phar|pl|py|cgi|sh)$">
            Require all denied
        </FilesMatch>
    </Directory>

    ErrorLog  /var/log/apache2/kuorum-error.log
    CustomLog /var/log/apache2/kuorum-access.log combined
</VirtualHost>

<VirtualHost *:80>
    ServerName kuorum.ejemplo.com
    Redirect permanent / https://kuorum.ejemplo.com/
</VirtualHost>
```

### 🔴 El problema del DocumentRoot (hallazgo HR-014)

El `.htaccess` de la raíz lo documenta explícitamente:

> *NOTA: esto es una mitigación, no la solución. La corrección real es que el DocumentRoot del vhost apunte a `public/` y no a la raíz del proyecto; mientras no sea así, `app/`, `core/`, `database/` y `.git/` siguen siendo alcanzables.*

| Configuración | Seguridad | Funcionalidad |
|---|---|---|
| DocumentRoot = raíz del proyecto | 🔴 `app/`, `core/`, `database/`, `.env`, `.git/` accesibles por HTTP, protegidos solo por `.htaccess` | ✅ `files/` y `core/ajax.js` funcionan sin configuración extra |
| DocumentRoot = `public/` | ✅ Correcta | ⚠️ Requiere `Alias /files` (y, si se usa `core/ajax.js`, exponerlo también o moverlo) |

> ⚠️ `core/ajax.js` y `core/check_sessions.js` viven **fuera de `public/`**. Comprueba si alguna vista los referencia por URL antes de mover el DocumentRoot.

### Sobre HTTPS detrás de un proxy inverso

`core/AutoLoad.php` detecta HTTPS así:

```php
$isSecure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
         || (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443);
```

**No mira `X-Forwarded-Proto`.** Detrás de un proxy que termine TLS, la cookie de sesión no se marcará `Secure` y no se emitirá `Strict-Transport-Security`. Solución en Apache:

```apache
SetEnvIf X-Forwarded-Proto "^https$" HTTPS=on
```

---

## 4. Migraciones

```bash
php bin/migrate status     # qué está aplicado y qué falta
php bin/migrate up         # aplica las pendientes, en un lote nuevo
php bin/migrate down       # revierte el último lote
php bin/migrate validate   # revisión estática, sin conectar a la BD
```

**Control:** tabla `migrations(id, migration, batch, executed_at)`.

**Estado en la base de desarrollo actual:** 41 ficheros, 36 aplicadas. Pendientes:

```
2026_05_13_000086_link_potencial_evaluaciones_to_general_periodos
2026_05_14_000087_fix_duplicate_metas_peso
2026_06_23_000094_fix_text_encoding_competencias_festivos
2026_06_24_000095_fix_text_encoding_planes_desarrollo_colaboradores
2026_06_24_000096_fix_text_encoding_planes_desarrollo_explicit_map
```

> ⚠️ **Haz copia de seguridad antes de `migrate up`.** Cinco de las migraciones pendientes modifican datos existentes (corrección de codificación de texto y de pesos duplicados), no solo estructura.

**Cómo `bin/migrate` obtiene la conexión:**
1. `ConfigEnv::$DB_CONNECTIONS['klee']` si la clase está cargada.
2. Respaldo con `getenv('DB_HOST')`, `getenv('DB_NAME')`, `getenv('DB_USER')`, **`getenv('DB_PASS')`**.

> 🔴 El respaldo lee **`DB_PASS`**, pero la clave real del `.env` es **`DB_PASSWORD`**. Además, `bin/migrate` **no llama a `Env::load()` ni a `Config::syncConfigFromEnv()`**, a diferencia de `bin/seed`, que sí lo hace y cuyo comentario documenta exactamente este error ya corregido allí. **Si `bin/migrate` no conecta, es por esto**: exporta las variables antes de invocarlo.
>
> ```bash
> export DB_HOST=localhost DB_NAME=kuorum DB_USER=kuorum DB_PASS='<contraseña>'
> php bin/migrate up
> ```

---

## 5. Semillas

```bash
php bin/seed                  # perfil base
php bin/seed base             # núcleo, catálogos y nómina base   (0xx)
php bin/seed modulos          # base + módulos funcionales         (0xx + 1xx)
php bin/seed completo         # todo, incluidos escenarios demo    (0xx + 1xx + 2xx)
php bin/seed CargosSeeder     # una sola semilla, por nombre de clase
php bin/seed --list           # perfiles y semillas disponibles
php bin/seed --validate       # revisión estática, sin BD
php bin/seed --verify-demo    # cobertura de datos por módulo
```

Alias heredados: `all` → `completo`, `modules` → `modulos`, `demo` → `completo`, `demo-complete` → `completo`.

| Entorno | Perfil |
|---|---|
| Producción | `base` |
| Preproducción | `modulos` |
| Demostración / desarrollo | `completo` |

Las semillas usan `Seeder::upsert()`, que es **idempotente**: se pueden reejecutar.

`bin/seed` **sí** carga `Env::load()` y `Config::syncConfigFromEnv()`, así que lee el `.env` correctamente.

> ⚠️ `DemoDataSeeder.php` no tiene prefijo numérico y **no participa en ningún perfil**. Solo se ejecuta invocándolo por nombre de clase.

---

## 6. Tareas programadas

**Solo hay dos endpoints de cron en todo el sistema.**

```cron
# Envío de alertas por correo, cada 10 minutos
*/10 * * * * curl -fsS -H "X-Cron-Token: ${CRON_TOKEN}" \
    "https://kuorum.ejemplo.com/?c=AlertasEmail&a=sendCron" >/dev/null 2>&1

# Envío de solicitudes al administrador
*/15 * * * * curl -fsS -H "X-Cron-Token: ${CRON_TOKEN}" \
    "https://kuorum.ejemplo.com/?c=AlertasEmail&a=sendCronSolicitudAdmin" >/dev/null 2>&1
```

Generar el token:

```bash
php -r "echo bin2hex(random_bytes(32));"
```

Alternativa al `X-Cron-Token`: `?token=<CRON_TOKEN>` (menos recomendable, queda en los logs del servidor).

Control de volumen desde la interfaz (`?c=configuraciones&a=view`):
- `DetenerEnvioNotificaciones` — interruptor de emergencia
- `EnvioMaximoNotificaciones` — correos por ejecución (por defecto 2 si no es numérico)

> 🔴 **No hay tareas programadas para:** vencimiento de inscripciones de capacitación (`capacitacion_inscripciones.Estado = 'vencido'`), recálculo periódico del SLA de tickets abiertos, generación de saldos de vacaciones al cambiar de año, alertas de contratos por vencer, ni depuración de logs en base de datos. Si el negocio las necesita, hay que implementarlas.

---

## 7. Permisos del sistema de ficheros

| Ruta | Permiso | Consecuencia si falta |
|---|---|---|
| `files/` y subcarpetas | **Escritura** del usuario del servidor web | Fallan las subidas de adjuntos, fotos, CV y evidencias |
| `storage/cache/` | **Escritura** | El classmap y la caché degradan **en silencio**; el autoload sigue funcionando por búsqueda de fichero (más lento) |
| `logs/` | **Escritura** | `Logger` no puede escribir |
| Resto | Solo lectura | |

```bash
chown -R www-data:www-data files storage logs
find files storage logs -type d -exec chmod 775 {} \;
find files storage logs -type f -exec chmod 664 {} \;
```

`Controller::CrearCarpetas($ruta)` crea directorios con `mkdir($ruta, 0755, true)`.

---

## 8. Caché

### Classmap del autoload

`storage/cache/classmap.php` — mapa clase → ruta, generado en el arranque.

```php
// core/AutoLoad.php
if (empty($autoloadClassMap)) {
    $autoloadClassMap = $autoloadBuildClassMap($autoloadSearchPaths);
    $autoloadPersistClassMap($autoloadClassMap, $autoloadClassMapPath);
}
```

Se refresca de forma incremental (marcando `$autoloadClassMapDirty` y reescribiendo en `register_shutdown_function`) cuando se resuelve una clase que no estaba en el mapa. **Solo se reconstruye entero si el fichero está vacío o no existe.**

**Tras un despliegue que añada, mueva o elimine clases:**

```bash
rm -f storage/cache/classmap.php
```

### Caché de consultas

`core/Cache.php`, en ficheros bajo `storage/cache/`. Usada por:
- Modelos con `$CACHE = true` — **ninguno hoy**
- `AsistenteIAService` — resultados de SQL, TTL `IA_QUERY_CACHE_TTL` (180 s)

Vaciado desde el código: `Cache::flush()` o `Cache::deleteNamespace('MiModelo')`.
Vaciado manual: borrar los ficheros de `storage/cache/` **salvo** `classmap.php` (o borrarlo también; se regenera).

### Marca de siembra del modo demo

`storage/cache/demo_data_ready.flag`, TTL 6 horas. Bórrala para forzar una nueva siembra de datos demo.

---

## 9. Logs

| Origen | Destino | Rotación |
|---|---|---|
| `Logger` / `LoggerManager` | Ficheros en `logs/` | Automática por tamaño (`Logger::rotateIfNeeded()`) |
| Errores de PHP | `error_log` del servidor | Configuración de PHP |
| `LogAccionesModel::saveAccess()` | Tabla `log_acceso` | ❌ **Ninguna** |
| Framework | Tabla `log_urls` | ❌ **Ninguna** |
| `Model::log()` | Tabla `log_modules` (con `OldData`/`NewData` en JSON) | ❌ **Ninguna** |
| `BitacoraAuditoriaModel::registrar()` | Tabla `bitacora_auditoria` | ❌ **Ninguna** |

> ⚠️ **Las cuatro tablas de auditoría crecen sin límite y no hay ninguna tarea de depuración.** `log_modules` almacena el JSON completo del antes y el después de cada cambio de `colaboradores` y `usuarios`. Planifica una política de retención.

Consulta desde el código:

```php
Logger::tail(100);
Logger::getByLevel('ERROR', 50);
Logger::getByUser($userId, 50);
LoggerManager::getStats();
```

Perfiles: `LoggerConfig::setProductionMode()` / `setDevelopmentMode()`. `LoggerConfig::getBackends($tipoEvento)` decide a qué destino va cada tipo.

---

## 10. Copia de seguridad

`[NO DETERMINADO EN EL CÓDIGO]` — no hay script ni política en el repositorio. Lo que **debe** respaldarse:

| Elemento | Motivo |
|---|---|
| Base de datos completa | 124 tablas + **62 vistas** |
| `files/` | Adjuntos, CV, evidencias, logotipos. **No está en git** |
| `.env` | No está en git. Sin él no se puede reconstruir el entorno |
| `app/config/ConfigEnv[.local].php` | No está en git |

```bash
# Esquema + datos + vistas + rutinas
mysqldump -u kuorum -p --routines --triggers --single-transaction kuorum \
  | gzip > kuorum_$(date +%F).sql.gz

tar czf files_$(date +%F).tar.gz files/
cp .env env_$(date +%F).bak    # guardar cifrado
```

### 🔴 Aviso al restaurar

Las 62 vistas se crean con **`SQL SECURITY DEFINER` y `DEFINER = root@localhost`**. Al restaurar en un servidor donde ese usuario no exista, todas las vistas fallarán con:

```
ERROR 1449: The user specified as a definer ('root'@'localhost') does not exist
```

Solución al restaurar:

```bash
# Eliminar la cláusula DEFINER del volcado
sed -E 's/DEFINER=`[^`]+`@`[^`]+`//g' kuorum.sql > kuorum_sin_definer.sql
```

---

## 11. Proceso de despliegue de una actualización

No hay automatización. Secuencia manual recomendada:

```bash
# 1. Copia de seguridad
mysqldump -u kuorum -p --single-transaction kuorum | gzip > pre_deploy_$(date +%F_%H%M).sql.gz
tar czf pre_deploy_files_$(date +%F_%H%M).tar.gz files/

# 2. Modo mantenimiento (opcional)
#    Existe app/views/public/mantenimiento.php, pero NO hay un interruptor
#    que lo active. [NO DETERMINADO EN EL CÓDIGO] cómo se activa.

# 3. Traer el código
git fetch && git checkout <tag-o-rama> && git pull

# 4. Dependencias
composer install --no-dev --optimize-autoloader

# 5. Invalidar el classmap
rm -f storage/cache/classmap.php

# 6. Migraciones
php bin/migrate status
php bin/migrate up

# 7. Semillas nuevas (solo las que aporte la versión)
php bin/seed --validate
php bin/seed <SeederNuevo>

# 8. Permisos (por si git cambió algo)
chown -R www-data:www-data files storage logs

# 9. Verificación
curl -I https://kuorum.ejemplo.com/
# Login del backoffice + login del portal + un listado + un formulario
```

### Después de un despliegue que cambie el menú o los permisos

**Los usuarios con sesión abierta seguirán viendo el menú y los permisos antiguos**, porque ambos se congelan en `$_SESSION[APP_ID]` durante el login. Opciones:

1. Pedir a los usuarios que cierren sesión y vuelvan a entrar.
2. Cambiar `APP_ID` en el `.env` → **invalida todas las sesiones de golpe** (medida contundente, avisar antes).

---

## 12. Pruebas

```bash
composer test                  # vendor/bin/phpunit --colors=always tests
vendor/bin/phpunit tests/MigratorTest.php
```

Requiere las dependencias de desarrollo (`composer install` sin `--no-dev`).

Las pruebas son de **contrato y análisis estático**, no de integración: en su mayoría no necesitan base de datos. `[NO DETERMINADO EN EL CÓDIGO]` si alguna requiere conexión — no hay `phpunit.xml` que declare el bootstrap.

---

## 13. Herramientas de verificación incluidas

| Comando | Qué comprueba |
|---|---|
| `php bin/migrate validate` | `DatabaseStructureAuditor`: convenciones de migraciones y semillas, sin conectar |
| `php bin/seed --validate` | Lo mismo, desde el CLI de semillas |
| `php bin/seed --verify-demo` | `DemoReadinessVerifier`: cobertura de datos por módulo funcional |
| `php bin/seed --list` | Perfiles y semillas, en orden de ejecución |
| `php bin/migrate status` | Estado de las migraciones |

---

## 14. Lista de comprobación de puesta en producción

### Seguridad
- [ ] `APP_DEBUG=false`
- [ ] `DEMO_MODE_ENABLED=false`
- [ ] `DIRECTORIO_ACTIVO=false`
- [ ] `LOGIN_MANUAL=false`
- [ ] `DocumentRoot` apuntando a `public/`
- [ ] `.env` no accesible por HTTP (`curl https://host/.env` → 403/404)
- [ ] `.git/` no accesible por HTTP
- [ ] HTTPS con certificado válido y redirección desde HTTP
- [ ] `SetEnvIf X-Forwarded-Proto "^https$" HTTPS=on` si hay proxy inverso
- [ ] Contraseña del administrador cambiada respecto a la sembrada
- [ ] `php_flag engine off` en `files/`
- [ ] `CRON_TOKEN` generado con `random_bytes`

### Funcionalidad
- [ ] Login del backoffice funciona
- [ ] Login del portal funciona (con selector de periodo)
- [ ] Un listado con DataTables carga datos
- [ ] Un formulario guarda correctamente
- [ ] La eliminación pide POST (el botón es un formulario, no un enlace)
- [ ] El correo sale (probar con `?c=AlertasEmail&a=send`)
- [ ] Los adjuntos se suben a `files/`
- [ ] El favicon y los logotipos cargan (verifica el `Alias /files`)

### Datos
- [ ] `php bin/migrate status` sin pendientes
- [ ] Existe al menos un periodo con `Mostrar = 1` (si no, el login del portal no ofrece opciones)
- [ ] `Config::$FILTERS_SESION['Periodo']['valueDefault']` (**29**) corresponde a un periodo existente, o hay periodos que lo sustituyan
- [ ] Los roles 1, 2 y 5 existen con sus permisos
- [ ] La configuración de la empresa está rellena (`EmpresaNombre`, `EmpresaNIT`, …)

### Operación
- [ ] Cron configurado para `sendCron`
- [ ] `files/`, `storage/cache/` y `logs/` con permiso de escritura
- [ ] Copia de seguridad programada (BD + `files/` + `.env`)
- [ ] Locale `es_CO.UTF-8` generada
- [ ] Política de retención definida para `log_acceso`, `log_urls`, `log_modules` y `bitacora_auditoria`

---

## 15. Lo que falta para un despliegue moderno

| Elemento | Estado |
|---|---|
| Dockerfile / docker-compose | ❌ No existe |
| CI/CD (GitHub Actions, GitLab CI…) | ❌ No existe |
| `phpunit.xml` | ❌ No existe |
| Análisis estático (PHPStan, Psalm) | ❌ No existe |
| Linter / formateador (PHP-CS-Fixer) | ❌ No existe |
| Endpoint de *health check* | ⚠️ Lo más parecido es `?c=api&a=main` (`["API KLEE"]`) |
| Interruptor de modo mantenimiento | ⚠️ La vista `public/mantenimiento.php` existe, pero no hay forma documentada de activarla |
| Migración con *zero downtime* | ❌ No contemplada |
| Versionado de assets (cache busting) | ❌ No existe |
| Monitorización / APM | ❌ No existe |

---

## Documentos relacionados
- [13_CONFIGURATION.md](13_CONFIGURATION.md)
- [16_TROUBLESHOOTING.md](16_TROUBLESHOOTING.md)
- [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md)
