# Especificación técnica — Plataforma SaaS de reportes multi-tenant

**Versión:** 3.0
**Propósito:** fuente única de verdad para que una IA de desarrollo construya el sistema completo sin tomar ninguna decisión de diseño por su cuenta. Cada pantalla, cada endpoint, cada transición de estado y cada regla de negocio debe implementarse tal como aquí se describe.

---

## 1. Resumen ejecutivo

Plataforma SaaS multi-tenant donde cada organización ("tenant") conecta su propia base de datos MySQL (local en el mismo cPanel o remota) y sus usuarios ejecutan consultas SQL de **solo lectura**, guardadas como "reportes" reutilizables, visibles según un nivel jerárquico de acceso.

### 1.1. Objetivos no negociables
1. Ningún usuario puede ejecutar SQL de escritura contra la base de un tenant.
2. Las credenciales de conexión se cifran en reposo y nunca se exponen en respuestas ni logs.
3. Aislamiento estricto entre tenants, verificado en cada request, no solo en la UI.
4. Todo reporte tiene un nivel mínimo de acceso, filtrado siempre en el backend.
5. Toda ejecución (éxito o fallo) se audita antes de responder al cliente.

---

## 2. Alcance

### 2.1. Dentro del alcance (MVP)
Registro self-service, verificación de correo, recuperación de contraseña, invitación de usuarios, conexiones a BD (local/remota) con prueba en vivo e introspección de esquema, editor de consultas con validación de solo-lectura, reportes con parámetros dinámicos y nivel de acceso, visualización en tabla/gráfico, exportación CSV/Excel, roles jerárquicos por tenant, panel de super-administración, auditoría.

### 2.2. Fuera de alcance
Facturación automática, constructor visual sin SQL, reportes programados por correo, motores de BD distintos a MySQL/MariaDB, apps móviles nativas, edición colaborativa en tiempo real.

---

## 3. Arquitectura general

```
Usuario (navegador)
   │
   ▼
Frontend — HTML + CSS + JS vanilla (SPA con router de hash)
   │ fetch() con credentials:'include', cookie de sesión httpOnly
   ▼
Backend API — PHP 8.1+ con PDO
   ├── Auth (registro, login, verificación, recuperación, invitaciones)
   ├── RBAC (roles jerárquicos por tenant)
   ├── Conexiones (cifrado, prueba, introspección)
   ├── Motor de consultas (validación solo-lectura, timeout, límite de filas)
   ├── Reportes (CRUD, ejecución, exportación)
   └── Auditoría
   │
   ▼
Base de datos de la PLATAFORMA (MySQL en cPanel)

   En tiempo de ejecución, por separado:
Conexión dinámica PDO → Base de datos del TENANT (solo SELECT)
```

**Regla arquitectónica clave:** dos pools PDO completamente separados — plataforma (lectura/escritura, credenciales de la app) y tenant (solo lectura, credenciales dinámicas descifradas por request). Nunca se intercambian.

**Modelo de sesión:** cookie de servidor httpOnly (`Set-Cookie: sesion_id=...; HttpOnly; Secure; SameSite=Strict`), nunca token en `localStorage`. El frontend nunca lee ni manipula el valor de la cookie.

---

## 4. Stack tecnológico

| Componente | Tecnología | Versión mínima |
|---|---|---|
| Backend | PHP + PDO (`pdo_mysql`) | 8.1 |
| BD plataforma | MySQL/MariaDB | 8.0 / 10.5 |
| Frontend | HTML5, CSS3, JS ES2020+ sin frameworks | — |
| Gráficos | Chart.js (CDN) | 4.x |
| Exportación Excel | PhpSpreadsheet | 1.29+ |
| Cifrado credenciales | `sodium_crypto_secretbox` | PHP 7.2+ |
| Correo | PHPMailer vía SMTP del propio cPanel | 6.x |
| Hosting | cPanel, PHP 8.1 vía MultiPHP Manager | — |

**Prohibido:** Laravel/Symfony completos, React/Vue/Angular, Node.js persistente en producción.

---

## 5. Modelo de datos de la plataforma

Orden de creación: `tenants` → `roles` → `users` → `tenant_connections` → `reports` → `query_execution_log` → `sessions` → `auth_tokens` → `platform_admins`.

```sql
CREATE TABLE tenants (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(150) NOT NULL,
    slug VARCHAR(100) NOT NULL UNIQUE,
    estado ENUM('activo','suspendido','pendiente_verificacion') NOT NULL DEFAULT 'pendiente_verificacion',
    plan ENUM('free','pro','enterprise') NOT NULL DEFAULT 'free',
    creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    actualizado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE roles (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT UNSIGNED NOT NULL,
    nombre VARCHAR(100) NOT NULL,
    nivel SMALLINT UNSIGNED NOT NULL,
    es_rol_por_defecto BOOLEAN NOT NULL DEFAULT FALSE,
    creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uq_nombre_tenant (tenant_id, nombre),
    FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE users (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT UNSIGNED NOT NULL,
    nombre_completo VARCHAR(150) NOT NULL,
    email VARCHAR(190) NOT NULL,
    password_hash VARCHAR(255) NULL,
    rol_id INT UNSIGNED NOT NULL,
    es_admin_tenant BOOLEAN NOT NULL DEFAULT FALSE,
    estado ENUM('activo','invitado','desactivado') NOT NULL DEFAULT 'invitado',
    intentos_fallidos SMALLINT UNSIGNED NOT NULL DEFAULT 0,
    bloqueado_hasta DATETIME NULL,
    ultimo_login_en DATETIME NULL,
    creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uq_email_tenant (tenant_id, email),
    FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
    FOREIGN KEY (rol_id) REFERENCES roles(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE tenant_connections (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT UNSIGNED NOT NULL,
    nombre VARCHAR(100) NOT NULL,
    tipo ENUM('local','remota') NOT NULL,
    host VARCHAR(255) NOT NULL,
    puerto SMALLINT UNSIGNED NOT NULL DEFAULT 3306,
    nombre_bd VARCHAR(100) NOT NULL,
    usuario_bd VARCHAR(100) NOT NULL,
    password_cifrado VARBINARY(512) NOT NULL,
    nonce VARBINARY(24) NOT NULL,
    ssl_requerido BOOLEAN NOT NULL DEFAULT FALSE,
    estado_conexion ENUM('no_probada','exitosa','fallida') NOT NULL DEFAULT 'no_probada',
    ultima_prueba_en DATETIME NULL,
    ultimo_error TEXT NULL,
    creado_por INT UNSIGNED NOT NULL,
    creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
    FOREIGN KEY (creado_por) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE reports (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT UNSIGNED NOT NULL,
    connection_id INT UNSIGNED NOT NULL,
    nombre VARCHAR(150) NOT NULL,
    descripcion TEXT NULL,
    sql_consulta MEDIUMTEXT NOT NULL,
    tipo_visualizacion ENUM('tabla','tabla_agrupada','grafico_barra','grafico_linea','grafico_torta') NOT NULL DEFAULT 'tabla',
    parametros_json JSON NULL,
    nivel_minimo_requerido SMALLINT UNSIGNED NOT NULL DEFAULT 0,
    creado_por INT UNSIGNED NOT NULL,
    creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    actualizado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
    FOREIGN KEY (connection_id) REFERENCES tenant_connections(id) ON DELETE RESTRICT,
    FOREIGN KEY (creado_por) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE query_execution_log (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT UNSIGNED NOT NULL,
    user_id INT UNSIGNED NOT NULL,
    report_id INT UNSIGNED NULL,
    sql_ejecutado MEDIUMTEXT NOT NULL,
    parametros_usados JSON NULL,
    exitoso BOOLEAN NOT NULL,
    codigo_error VARCHAR(50) NULL,
    mensaje_error TEXT NULL,
    duracion_ms INT UNSIGNED NULL,
    filas_retornadas INT UNSIGNED NULL,
    filas_truncadas BOOLEAN NOT NULL DEFAULT FALSE,
    ejecutado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE SET NULL,
    INDEX idx_tenant_fecha (tenant_id, ejecutado_en)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE sessions (
    id VARCHAR(128) PRIMARY KEY,
    user_id INT UNSIGNED NOT NULL,
    tenant_id INT UNSIGNED NOT NULL,
    ip_address VARCHAR(45) NULL,
    user_agent VARCHAR(255) NULL,
    creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expira_en DATETIME NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE auth_tokens (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id INT UNSIGNED NOT NULL,
    tipo ENUM('verificacion_correo','recuperacion_password','invitacion') NOT NULL,
    token_hash VARCHAR(255) NOT NULL COMMENT 'SHA-256 del token; el texto plano solo va en el correo',
    usado BOOLEAN NOT NULL DEFAULT FALSE,
    expira_en DATETIME NOT NULL,
    creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    INDEX idx_token_hash (token_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Super-administradores de la plataforma, fuera de la jerarquia de cualquier tenant.
CREATE TABLE platform_admins (
    user_id INT UNSIGNED PRIMARY KEY,
    creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

### 5.1. Estados y transiciones de cada entidad

**`tenants.estado`**
```
pendiente_verificacion ──(correo del admin verificado)──▶ activo
activo ──(super-admin suspende)──▶ suspendido
suspendido ──(super-admin reactiva)──▶ activo
```
No existe transición de vuelta a `pendiente_verificacion`. No existe estado "eliminado": un tenant no se borra físicamente en el MVP (solo se suspende); el borrado físico queda fuera de alcance.

**`users.estado`**
```
invitado ──(fija su password via token de invitacion, o verifica correo si es el admin fundador)──▶ activo
activo ──(admin del tenant lo desactiva)──▶ desactivado
desactivado ──(admin del tenant lo reactiva)──▶ activo
```
Regla: un usuario en `invitado` no puede autenticarse (`USUARIO_NO_ACTIVO`) hasta completar su transición a `activo`.

**`tenant_connections.estado_conexion`**
```
no_probada ──(prueba exitosa)──▶ exitosa
no_probada ──(prueba fallida)──▶ fallida
exitosa ──(se vuelve a probar y falla, ej. cambio de credenciales en origen)──▶ fallida
fallida ──(se vuelve a probar y ahora funciona)──▶ exitosa
```
Regla: solo las conexiones en `exitosa` aparecen como opción seleccionable en el editor de reportes (sección 9.7); una conexión en `fallida` sigue existiendo y sus reportes ya creados no se borran, pero no se pueden ejecutar (error `CONEXION_FALLIDA`) hasta que vuelva a `exitosa`.

**`auth_tokens.usado`**
```
false ──(se consume exitosamente: verificación, reset o invitación aceptada)──▶ true
```
Un token usado o vencido (`expira_en < NOW()`) siempre responde `TOKEN_INVALIDO_O_EXPIRADO`, sin distinguir cuál de las dos causas fue, para no dar pistas a quien intente adivinar tokens.

---

## 6. Estructura de carpetas

```
/public_html/
├── index.php
├── assets/
│   ├── css/app.css
│   ├── js/{app.js, api.js, auth.js, reportes.js, conexiones.js, admin.js, charts.js, dashboard.js}
│   └── img/
├── api/
│   ├── index.php
│   ├── config/{database.php, env.php}
│   ├── src/
│   │   ├── Auth/{AuthController.php, SessionManager.php, TokenManager.php, PasswordResetController.php, InvitationController.php}
│   │   ├── Tenants/{TenantController.php, TenantOnboarding.php}
│   │   ├── Connections/{ConnectionController.php, ConnectionCrypto.php, ConnectionTester.php}
│   │   ├── SchemaIntrospection/SchemaReader.php
│   │   ├── QueryEngine/{QueryValidator.php, QueryExecutor.php, ParameterBinder.php}
│   │   ├── Reports/{ReportController.php, ReportExporter.php}
│   │   ├── RBAC/{RoleController.php, PermissionChecker.php}
│   │   ├── Dashboard/DashboardController.php
│   │   ├── Audit/AuditLogger.php
│   │   └── Common/{Response.php, Request.php, Validator.php, Middleware/{RequireAuth.php, RequireTenantMatch.php, RequireRole.php, RequireSuperAdmin.php}}
│   └── vendor/
├── .env / .env.example
└── .htaccess
```

---

## 7. Especificación completa de la API

Formato estándar de respuesta:
```json
{ "exito": true, "datos": { }, "error": null }
```
Error: `{ "exito": false, "datos": null, "error": { "codigo": "...", "mensaje": "..." } }`

### 7.0. Convenciones de validación de campos (aplican en todo el sistema)

| Campo | Patrón/regla exacta |
|---|---|
| `slug` (tenant) | `^[a-z0-9]+(-[a-z0-9]+)*$`, 3 a 50 caracteres |
| `email` | RFC 5322 simplificado: `^[^\s@]+@[^\s@]+\.[^\s@]+$`, máximo 190 caracteres |
| `password` | mínimo 8 caracteres, al menos 1 dígito, al menos 1 letra |
| `nombre_completo` / `nombre` (organización, conexión, reporte, rol) | 3 a 150 caracteres, sin `<` ni `>` (previene HTML/XSS al renderizar) |
| `nivel` (rol) | entero, 0 a 100 |
| `puerto` | entero, 1 a 65535 |
| nombre de parámetro de reporte (`parametros[].nombre`) | `^[a-zA-Z_][a-zA-Z0-9_]*$`, debe existir como `:nombre` dentro de `sql_consulta` |

Toda validación de texto se hace primero en el cliente (para feedback inmediato) y **siempre de nuevo en el servidor** (el cliente nunca es la única barrera).

### 7.1. `GET /api/tenants/verificar-slug?slug=...`
Sin autenticación. Devuelve `{ "disponible": true }` o `{ "disponible": false }`. Uso exclusivo: feedback en vivo del campo `slug` en el formulario de registro (sección 9.1); no reemplaza la validación de unicidad que ocurre de nuevo, atómicamente, dentro de 7.2.

### 7.2. `POST /api/auth/registro-tenant`
**Petición:**
```json
{
  "nombre_organizacion": "Comunidad Ejemplo",
  "slug": "comunidad-ejemplo",
  "admin_nombre": "Ana Torres",
  "admin_email": "ana@ejemplo.org",
  "admin_password": "Segura123"
}
```
**Reglas de negocio, en orden:**
1. Revalidar disponibilidad de `slug` dentro de una transacción (evita condición de carrera con 7.1).
2. Validar `admin_password` contra 7.0 → si falla, `VALIDACION_FALLIDA`.
3. Insertar `tenants` (`estado='pendiente_verificacion'`).
4. Insertar `roles` (`nombre='Administrador'`, `nivel=100`, `es_rol_por_defecto=true`).
5. Insertar `users` (`estado='invitado'`, `es_admin_tenant=true`, `password_hash=password_hash($password, PASSWORD_ARGON2ID)`).
6. Insertar `auth_tokens` (`tipo='verificacion_correo'`, `expira_en=NOW()+INTERVAL 24 HOUR`), enviar correo con enlace `#/verificar-correo?token=...`.

**Respuesta:** `{ "tenant_id": 1, "mensaje": "Revisa tu correo para verificar tu cuenta." }`

### 7.3. `GET /api/auth/verificar-correo?token=...`
Busca `auth_tokens` por `SHA2(token,256)=token_hash AND tipo='verificacion_correo' AND usado=false AND expira_en>NOW()`. Si no existe: `TOKEN_INVALIDO_O_EXPIRADO`. Si existe: marca `usado=true`, `users.estado='activo'`, `tenants.estado='activo'`.
**Respuesta:** `{ "mensaje": "Correo verificado. Ya puedes iniciar sesión.", "slug_tenant": "comunidad-ejemplo" }`

### 7.4. `POST /api/auth/login`
**Petición:** `{ "slug_tenant": "comunidad-ejemplo", "email": "ana@ejemplo.org", "password": "Segura123", "recordarme": false }`

**Reglas, en orden estricto:** tenant existe y `activo` (si no → `TENANT_SUSPENDIDO`) → usuario existe en ese tenant (si no → `CREDENCIALES_INVALIDAS`) → no bloqueado (`bloqueado_hasta`) (si sí → `CUENTA_BLOQUEADA_TEMPORALMENTE`) → `password_verify` correcto (si falla, incrementar `intentos_fallidos`; al llegar a 5, `bloqueado_hasta=NOW()+INTERVAL 15 MINUTE` y reiniciar contador; responder `CREDENCIALES_INVALIDAS`) → `estado='activo'` (si no → `USUARIO_NO_ACTIVO`) → éxito: `intentos_fallidos=0`, `ultimo_login_en=NOW()`, crear `sessions` (`expira_en = NOW()+INTERVAL 8 HOUR` o `+INTERVAL 30 DAY` si `recordarme`), cookie httpOnly.

**Respuesta:**
```json
{
  "usuario": {"id":12,"nombre_completo":"Ana Torres","email":"ana@ejemplo.org","rol":{"id":3,"nombre":"Administrador","nivel":100},"es_admin_tenant":true},
  "tenant": {"id":1,"nombre":"Comunidad Ejemplo","slug":"comunidad-ejemplo"}
}
```

### 7.5. `POST /api/auth/logout`
Elimina la fila de `sessions` de la cookie actual, borra la cookie. Respuesta: `{ "mensaje": "Sesión cerrada." }`

### 7.6. `GET /api/auth/yo`
Igual forma que 7.4 a partir de la cookie activa. Error `SESION_EXPIRADA` si no hay sesión válida o el tenant asociado ya no está `activo`.

### 7.7. `POST /api/auth/recuperar-password`
**Petición:** `{ "slug_tenant": "comunidad-ejemplo", "email": "ana@ejemplo.org" }`
**Regla de negocio:** exista o no el usuario, la respuesta es siempre la misma (`{"mensaje":"Si el correo existe, enviaremos instrucciones."}`, HTTP 200) para no revelar qué correos están registrados. Si existe: generar `auth_tokens` (`tipo='recuperacion_password'`, `expira_en=NOW()+INTERVAL 1 HOUR`) y enviar correo con enlace `#/restablecer-password?token=...`.

### 7.8. `POST /api/auth/restablecer-password`
**Petición:** `{ "token": "...", "password_nueva": "NuevaSegura123", "password_nueva_confirmacion": "NuevaSegura123" }`
**Reglas:** token válido y no usado (si no → `TOKEN_INVALIDO_O_EXPIRADO`) → ambas contraseñas coinciden y cumplen 7.0 (si no → `VALIDACION_FALLIDA`) → actualizar `password_hash`, marcar token `usado=true`, **eliminar todas las `sessions` existentes de ese usuario** (fuerza cierre de sesión en cualquier dispositivo donde estuviera conectado, por seguridad).

### 7.9. `POST /api/auth/aceptar-invitacion`
**Petición:** `{ "token": "...", "password": "Segura123", "password_confirmacion": "Segura123" }`
**Reglas:** token válido, tipo `invitacion`, no usado (si no → `TOKEN_INVALIDO_O_EXPIRADO`) → validar password (7.0) → `users.password_hash` se fija, `users.estado='activo'`, token `usado=true`. Redirige a login tras éxito.

### 7.10. `GET /api/dashboard/resumen`
**Autenticación:** requerida.
**Respuesta:**
```json
{
  "total_reportes_visibles": 14,
  "ejecuciones_ultimos_7_dias": 42,
  "reportes_recientes": [
    {"id":5,"nombre":"Ventas por mes","ultima_ejecucion_en":"2026-07-10T14:32:00","exitosa":true}
  ],
  "conexiones": [{"id":2,"nombre":"Base principal","estado_conexion":"exitosa"}]
}
```
**Regla de negocio:** `reportes_recientes` y `total_reportes_visibles` respetan el mismo filtro de nivel de acceso que 7.16 (nunca se cuentan ni se listan reportes que el usuario no podría ejecutar).

### 7.11. `POST /api/conexiones`
**Autenticación:** `es_admin_tenant=true`.
**Petición (tipo local):**
```json
{ "nombre": "Base principal", "tipo": "local", "nombre_bd": "usuario_ventas" }
```
**Petición (tipo remota):**
```json
{ "nombre": "Base externa Concar", "tipo": "remota", "host": "200.1.2.3", "puerto": 3306, "nombre_bd": "concar_prod", "usuario_bd": "reportes_ro", "password_bd": "claveGeneradaPorElCliente", "ssl_requerido": false }
```
**Reglas:** si `tipo=local`, el sistema ignora cualquier `usuario_bd`/`password_bd` recibido y ejecuta el aprovisionamiento automático (sección 8.2) generando credenciales de solo lectura aleatorias de 32 caracteres; si `tipo=remota`, se cifran con `sodium_crypto_secretbox` las credenciales recibidas tal cual. Se guarda con `estado_conexion='no_probada'`.

**Respuesta:** `{ "id": 7, "nombre": "Base principal", "tipo": "local", "estado_conexion": "no_probada" }` (nunca incluye `usuario_bd` ni ninguna credencial).

### 7.12. `POST /api/conexiones/{id}/probar`
Descifra credenciales, `PDO` con `ATTR_TIMEOUT=5`, ejecuta `SELECT 1`. Éxito → `estado_conexion='exitosa'`, se ejecuta también la introspección (7.13) y se retorna junto con la confirmación. Falla → `estado_conexion='fallida'`, mapeo de errores:
- `[2002]` host inalcanzable → "No se pudo alcanzar el servidor. Verifica el host y que el firewall permita conexiones remotas."
- `[1045]` acceso denegado → "Usuario o contraseña incorrectos."
- `[1049]` base inexistente → "La base de datos indicada no existe en ese servidor."

**Respuesta éxito:** `{ "estado_conexion": "exitosa", "esquema": { "tablas": [...] } }`
**Respuesta falla:** `{ "estado_conexion": "fallida", "error_detalle": "No se pudo alcanzar el servidor..." }`

### 7.13. `GET /api/conexiones/{id}/esquema`
```sql
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_KEY
FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = :nombre_bd
ORDER BY TABLE_NAME, ORDINAL_POSITION;
```
**Respuesta:** `{ "tablas": [ {"nombre":"ventas","columnas":[{"nombre":"id","tipo":"int","llave":"PRI"},{"nombre":"total","tipo":"decimal","llave":null}]} ] }`

### 7.14. `GET /api/conexiones`
**Respuesta:** `[{"id":7,"nombre":"Base principal","tipo":"local","estado_conexion":"exitosa","ultima_prueba_en":"2026-07-11T10:00:00"}]`

### 7.15. `DELETE /api/conexiones/{id}`
Regla: `ON DELETE RESTRICT` en `reports.connection_id` — si hay reportes asociados, MySQL rechaza el borrado; la API traduce el error a `{"codigo":"CONEXION_EN_USO","mensaje":"No se puede eliminar: existen 3 reportes usando esta conexión."}` (HTTP 409).

### 7.16. `GET /api/reportes?busqueda=&tipo=`
Filtro obligatorio en SQL: `WHERE tenant_id=:tenant_sesion AND nivel_minimo_requerido<=:nivel_usuario_sesion`, más `AND nombre LIKE :busqueda` y `AND tipo_visualizacion=:tipo` si vienen.
**Respuesta:** `[{"id":5,"nombre":"Ventas por mes","descripcion":"...","tipo_visualizacion":"grafico_barra","nivel_minimo_requerido":50,"ultima_ejecucion_en":"2026-07-10T14:32:00","ultima_ejecucion_exitosa":true}]`

### 7.17. `GET /api/reportes/{id}`
Devuelve la definición completa (incluye `parametros_json`, `sql_consulta` **solo si** el usuario que consulta es `es_admin_tenant=true`; un usuario no-admin recibe la definición sin el campo `sql_consulta`, ya que no necesita verla para ejecutar el reporte y no debería poder inspeccionar la consulta subyacente).

### 7.18. `POST /api/reportes/validar-consulta`
**Petición:** `{ "connection_id": 7, "sql_consulta": "SELECT mes, SUM(total) AS total FROM ventas WHERE fecha BETWEEN :fecha_inicio AND :fecha_fin GROUP BY mes" }`
**Reglas:** `QueryValidator` (8.1) → si falla, `SOLO_LECTURA_VIOLADA`. Si pasa: `EXPLAIN` contra la conexión real → columnas resultantes + placeholders detectados vía regex `/:([a-zA-Z_][a-zA-Z0-9_]*)/`.
**Respuesta:** `{ "valida": true, "columnas_detectadas": ["mes","total"], "parametros_detectados": ["fecha_inicio","fecha_fin"] }`

### 7.19. `POST /api/reportes` y `PUT /api/reportes/{id}`
**Petición:**
```json
{
  "connection_id": 7,
  "nombre": "Ventas por mes",
  "descripcion": "Total de ventas agrupado por mes",
  "sql_consulta": "SELECT mes, SUM(total) AS total FROM ventas WHERE fecha BETWEEN :fecha_inicio AND :fecha_fin GROUP BY mes",
  "tipo_visualizacion": "grafico_barra",
  "nivel_minimo_requerido": 50,
  "parametros": [
    {"nombre":"fecha_inicio","etiqueta":"Desde","tipo":"fecha","requerido":true},
    {"nombre":"fecha_fin","etiqueta":"Hasta","tipo":"fecha","requerido":true}
  ]
}
```
**Reglas:** `connection_id` pertenece al tenant (si no, `TENANT_NO_COINCIDE`) → `QueryValidator` (si falla, `SOLO_LECTURA_VIOLADA`) → todo placeholder `:nombre` tiene su entrada en `parametros` y viceversa (si no, `VALIDACION_FALLIDA` listando los que sobran/faltan) → insertar/actualizar con `creado_por` del usuario de sesión.
**Respuesta:** `{ "id": 5, "nombre": "Ventas por mes" }`

### 7.20. `POST /api/reportes/{id}/ejecutar`
**Petición:** `{ "parametros": { "fecha_inicio": "2026-01-01", "fecha_fin": "2026-06-30" } }`
**Reglas, en orden:** tenant coincide (si no, `TENANT_NO_COINCIDE`) → `rol.nivel >= nivel_minimo_requerido` (si no, `NIVEL_ACCESO_INSUFICIENTE`) → rate limit 30/min por usuario (si excede, `LIMITE_TASA_EXCEDIDO`) → cada parámetro válido contra su definición (si no, `PARAMETRO_INVALIDO`) → conexión PDO dinámica descifrando credenciales → `prepare()`/`bindValue()` (nunca concatenación) → `ATTR_TIMEOUT` + `SET SESSION MAX_EXECUTION_TIME=10000` → si no hay `LIMIT` propio, forzar `LIMIT 5001` y truncar a 5000 marcando `filas_truncadas=true` si se alcanzó → si `tipo_visualizacion` es gráfico, exigir ≥2 columnas con la primera como etiqueta (si no, `TIPO_VISUALIZACION_INCOMPATIBLE`) → insertar en `query_execution_log` antes de responder.
**Respuesta:**
```json
{ "columnas":[{"nombre":"mes","tipo":"varchar"},{"nombre":"total","tipo":"decimal"}], "filas":[["Enero",1200.50],["Febrero",980.00]], "filas_truncadas": false, "duracion_ms": 142 }
```

### 7.21. `GET /api/reportes/{id}/exportar?formato=xlsx|csv`
Reejecuta la lógica de 7.20 con los últimos parámetros enviados por el cliente (reenviados como query string codificados, no re-solicitados al usuario), transforma con PhpSpreadsheet (`xlsx`) o `fputcsv` (`csv`), y también inserta una fila en `query_execution_log`.

### 7.22. `DELETE /api/reportes/{id}`
Solo `es_admin_tenant=true`. Borrado físico; `query_execution_log.report_id` pasa a `NULL` (`ON DELETE SET NULL`), preservando el historial de auditoría aunque el reporte ya no exista.

### 7.23. Roles — `GET/POST /api/roles`, `PUT/DELETE /api/roles/{id}`
**Petición POST/PUT:** `{ "nombre": "Coordinador", "nivel": 50 }`
**Respuesta GET:** `[{"id":3,"nombre":"Administrador","nivel":100,"es_rol_por_defecto":true,"cantidad_usuarios":2},{"id":4,"nombre":"Coordinador","nivel":50,"es_rol_por_defecto":false,"cantidad_usuarios":5}]`
**Reglas DELETE:** rechazado si `es_rol_por_defecto=true` (`ROL_POR_DEFECTO_NO_ELIMINABLE`) o si `EXISTS(SELECT 1 FROM users WHERE rol_id=:id)` (`ROL_EN_USO`, incluyendo en el mensaje la cantidad de usuarios afectados).

### 7.24. Usuarios — `GET /api/usuarios`, `POST /api/usuarios/invitar`, `PUT /api/usuarios/{id}/estado`, `PUT /api/usuarios/{id}/rol`
**`GET /api/usuarios` respuesta:** `[{"id":12,"nombre_completo":"Ana Torres","email":"ana@ejemplo.org","rol":{"id":3,"nombre":"Administrador"},"estado":"activo","ultimo_login_en":"2026-07-11T09:00:00"}]`
**`POST /api/usuarios/invitar` petición:** `{ "nombre_completo": "Luis Pérez", "email": "luis@ejemplo.org", "rol_id": 4 }` → crea `users` (`estado='invitado'`, `password_hash=NULL`), `auth_tokens` (`tipo='invitacion'`, `expira_en=NOW()+INTERVAL 7 DAY`), envía correo con enlace `#/aceptar-invitacion?token=...`.
**`PUT /api/usuarios/{id}/estado` petición:** `{ "estado": "desactivado" }` → elimina inmediatamente todas las `sessions` de ese usuario.

### 7.25. Super-administración — `GET /api/plataforma/tenants`, `PUT /api/plataforma/tenants/{id}/estado`
**Autenticación:** requiere fila en `platform_admins` para el usuario de sesión (middleware `RequireSuperAdmin`, independiente de cualquier `tenant_id`).
**Respuesta GET:** `[{"id":1,"nombre":"Comunidad Ejemplo","slug":"comunidad-ejemplo","estado":"activo","plan":"free","creado_en":"2026-06-01","cantidad_usuarios":7,"ejecuciones_ultimo_mes":210}]`
**`PUT .../estado` petición:** `{ "estado": "suspendido" }` → en la misma transacción, elimina todas las `sessions` de todos los usuarios de ese tenant.

---

## 8. Reglas de seguridad — implementación obligatoria

### 8.1. Validación de solo-lectura (`QueryValidator.php`)
1. Eliminar comentarios SQL (`--`, `/* */`) antes de analizar.
2. La consulta debe comenzar (tras espacios) con `SELECT` o `WITH`, case-insensitive.
3. Rechazar si aparece, fuera de literales de string, cualquiera de: `INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, CREATE, GRANT, REVOKE, RENAME, REPLACE, LOCK, UNLOCK, CALL, EXECUTE, SET`.
4. Rechazar múltiples sentencias separadas por `;` (solo una sentencia por ejecución).
5. Rechazar `INTO OUTFILE` / `INTO DUMPFILE`.
6. Esta validación es una capa adicional — la defensa principal es el usuario MySQL de solo `SELECT` (8.2).

### 8.2. Usuario MySQL de solo lectura por tenant
Local (aprovisionamiento automático):
```sql
CREATE USER 'nombre_tenant_ro'@'localhost' IDENTIFIED BY 'contraseña_aleatoria_32_caracteres';
GRANT SELECT ON nombre_bd_tenant.* TO 'nombre_tenant_ro'@'localhost';
FLUSH PRIVILEGES;
```
Remota (el usuario lo ejecuta en su propio servidor y solo ingresa las credenciales resultantes): mismo script, sustituyendo `'localhost'` por el host que su proveedor indique para conexiones remotas.

### 8.3. Cifrado de credenciales
```php
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cifrado = sodium_crypto_secretbox($password_texto_plano, $nonce, $CLAVE_MAESTRA);
// Descifrado:
$password_texto_plano = sodium_crypto_secretbox_open($cifrado, $nonce, $CLAVE_MAESTRA);
```
`$CLAVE_MAESTRA` vive solo en `.env`, nunca en la base ni en el código.

### 8.4. Aislamiento entre tenants
Toda consulta a `reports`, `tenant_connections`, `users`, `roles`, `query_execution_log` incluye `WHERE tenant_id=:tenant_sesion` tomado de la sesión — nunca de un parámetro enviado por el cliente.

### 8.5. Límites de ejecución
Timeout 10s, límite 5,000 filas, rate limit 30 ejecuciones/usuario/minuto.

### 8.6. Auditoría obligatoria
Toda ejecución (éxito o fallo) se inserta en `query_execution_log` **antes** de responder al cliente.

---

## 9. Especificación detallada de interfaces

### 9.0. Layout general (aplica a todas las pantallas autenticadas)

- **Encabezado superior fijo** (altura ~64px): logo/nombre de la organización a la izquierda, menú de usuario a la derecha (nombre, rol, enlace "Cerrar sesión").
- **Barra lateral izquierda** (ancho ~220px, colapsable en pantallas angostas): enlaces a Dashboard, Reportes, Conexiones, Usuarios, Roles (estos dos últimos solo visibles si `es_admin_tenant=true`), y — solo para el super-admin de plataforma — un enlace adicional "Administración de tenants".
- **Área de contenido principal**: el resto del ancho, con padding consistente de 24px.
- Las pantallas de **Login**, **Registro**, **Verificación de correo**, **Recuperar/Restablecer contraseña** y **Aceptar invitación** son las únicas sin encabezado ni barra lateral (layout centrado de una sola columna, máximo 420px de ancho, sin sesión activa).

### 9.1. Mapa de navegación entre pantallas

```
(sin sesión)
Registro (#/registro) ──envía formulario──▶ "Revisa tu correo" (pantalla estática)
   correo recibido ──clic en enlace──▶ Verificación de correo (#/verificar-correo?token=)
        ──éxito──▶ Login (#/login, con slug prellenado)

Login (#/login) ──credenciales correctas──▶ Dashboard (#/dashboard)
   ──"olvidé mi contraseña"──▶ Recuperar contraseña (#/recuperar-password)
        ──correo enviado──▶ (usuario revisa correo) ──clic──▶ Restablecer contraseña (#/restablecer-password?token=)
             ──éxito──▶ Login

Invitación por correo ──clic en enlace──▶ Aceptar invitación (#/aceptar-invitacion?token=)
   ──fija contraseña──▶ Login

(con sesión)
Dashboard ──clic en reporte reciente──▶ Ejecución de reporte (#/reportes/{id}/ejecutar)
Dashboard ──clic "Ver todos"──▶ Listado de reportes (#/reportes)

Listado de reportes ──clic "Ejecutar"──▶ Ejecución de reporte (#/reportes/{id}/ejecutar)
Listado de reportes ──clic "+ Nuevo reporte" (solo admin)──▶ Editor de reportes (#/reportes/nuevo)
Listado de reportes ──clic "Editar" en un reporte (solo admin)──▶ Editor de reportes (#/reportes/{id}/editar)

Editor de reportes ──"Validar consulta"──▶ (misma pantalla, se rellenan columnas/parámetros detectados)
Editor de reportes ──"Guardar"──▶ Listado de reportes

Barra lateral "Conexiones" ──"+ Nueva conexión"──▶ Wizard de conexión (#/conexiones/nueva), 3 pasos internos
Wizard de conexión ──prueba exitosa, "Continuar a reportes"──▶ Listado de reportes
Wizard de conexión ──prueba fallida, "Guardar y probar más tarde"──▶ Listado de conexiones (#/conexiones)

Barra lateral "Usuarios" (solo admin) ──▶ Gestión de usuarios (#/admin/usuarios)
Gestión de usuarios ──"Invitar usuario"──▶ (modal, no navega)

Barra lateral "Roles" (solo admin) ──▶ Gestión de roles (#/admin/roles)

Barra lateral "Administración de tenants" (solo super-admin) ──▶ Panel de super-administración (#/plataforma/tenants)
```

### 9.2. Pantalla: Registro de tenant (`#/registro`)
- `nombre_organizacion` (texto, obligatorio) — al perder foco, autogenera `slug` sugerido.
- `slug` (texto, obligatorio) — debounce 500ms → `GET /api/tenants/verificar-slug`, ✓/✗ en vivo.
- `admin_nombre` (texto, obligatorio).
- `admin_email` (email, obligatorio) — validación de formato en cliente; unicidad solo se conoce al enviar.
- `admin_password` (password, obligatorio) — medidor de fuerza visual, sin bloquear salvo incumplir el mínimo.
- `admin_password_confirmacion` (password, obligatorio) — debe coincidir, verificado en cliente.
- checkbox `acepto_terminos` (obligatorio) — botón deshabilitado hasta marcarlo.
- **Envía:** `POST /api/auth/registro-tenant` (7.2). Éxito → pantalla estática "Revisa tu correo". Error `SLUG_NO_DISPONIBLE` → resalta `slug`; `VALIDACION_FALLIDA` → resalta el campo indicado por el backend.

### 9.3. Pantalla: Verificación de correo (`#/verificar-correo?token=...`)
Sin formulario — al cargar, dispara automáticamente `GET /api/auth/verificar-correo?token=...`. Mientras espera: spinner "Verificando tu cuenta...". Éxito → mensaje "¡Cuenta verificada!" con botón "Ir a iniciar sesión" (navega a `#/login?tenant=slug_tenant` usando el `slug_tenant` de la respuesta). Error `TOKEN_INVALIDO_O_EXPIRADO` → mensaje "Este enlace ya no es válido" con enlace a "Solicitar uno nuevo" (vuelve a 9.2, no hay reenvío directo de verificación en el MVP — el usuario debe registrarse de nuevo si el token expiró, dado que no hay endpoint de reenvío en el alcance actual).

### 9.4. Pantalla: Login (`#/login`)
- `slug_tenant` (texto, obligatorio) — prellenado si llega `?tenant=slug` en la URL.
- `email` (email, obligatorio).
- `password` (password, obligatorio).
- checkbox `recordarme` (opcional).
- Enlace "¿Olvidaste tu contraseña?" → `#/recuperar-password`.
- **Envía:** `POST /api/auth/login` (7.4). Tras 3 respuestas `CREDENCIALES_INVALIDAS` consecutivas, resaltar más el enlace de recuperación (el bloqueo real de 5 intentos es del backend).
- Éxito → `#/dashboard`.

### 9.5. Pantalla: Recuperar contraseña (`#/recuperar-password`)
- `slug_tenant` (texto, obligatorio).
- `email` (email, obligatorio).
- **Envía:** `POST /api/auth/recuperar-password` (7.7). Siempre muestra el mismo mensaje de éxito ("Si el correo existe, enviaremos instrucciones"), sin importar la respuesta real del backend, para no revelar existencia de cuentas ni siquiera mediante diferencias de temporización perceptibles en la UI.

### 9.6. Pantalla: Restablecer contraseña (`#/restablecer-password?token=...`)
- `password_nueva` (password, obligatorio) — mismo medidor de fuerza que 9.2.
- `password_nueva_confirmacion` (password, obligatorio) — debe coincidir.
- **Envía:** `POST /api/auth/restablecer-password` (7.8). Éxito → mensaje "Contraseña actualizada. Se cerraron todas tus sesiones activas por seguridad." con botón a `#/login`. Error `TOKEN_INVALIDO_O_EXPIRADO` → mismo tratamiento que 9.3.

### 9.7. Pantalla: Aceptar invitación (`#/aceptar-invitacion?token=...`)
- `password` (password, obligatorio).
- `password_confirmacion` (password, obligatorio).
- **Envía:** `POST /api/auth/aceptar-invitacion` (7.9). Éxito → redirige a `#/login` con mensaje "Cuenta activada, ya puedes iniciar sesión."

### 9.8. Pantalla: Dashboard (`#/dashboard`)
Al cargar: `GET /api/dashboard/resumen` (7.10). Muestra tres bloques: (a) tarjeta con `total_reportes_visibles` y `ejecuciones_ultimos_7_dias` como números grandes; (b) lista de `reportes_recientes` (nombre, fecha, ✓/✗), cada fila clicable hacia `#/reportes/{id}/ejecutar`; (c) lista de `conexiones` con su `estado_conexion` (badge verde/rojo/gris), clicable hacia `#/conexiones`. Botón "Ver todos los reportes" → `#/reportes`.

### 9.9. Pantalla: Wizard de conexión (`#/conexiones/nueva`)
**Paso 1:** `tipo` (radio: "En este mismo servidor" / "En un servidor externo", obligatorio).
**Paso 2a (local):** `nombre` (texto, obligatorio), `nombre_bd` (texto, obligatorio, con texto de ayuda sobre el prefijo de cPanel).
**Paso 2b (remota):** bloque de solo lectura con el script SQL de 8.2 y botón "Copiar"; `nombre` (texto, obligatorio); `host` (texto, obligatorio); `puerto` (numérico, opcional, default 3306); `nombre_bd` (texto, obligatorio); `usuario_bd` (texto, obligatorio, con advertencia roja de "nunca un usuario con permisos de escritura"); `password_bd` (password, obligatorio); checkbox `ssl_requerido` (opcional).
**Paso 3:** botón "Probar conexión" → `POST /api/conexiones` (7.11) seguido de `POST /api/conexiones/{id}/probar` (7.12). Éxito → tarjeta verde con resumen del esquema + botón "Continuar a reportes" (→ `#/reportes`). Falla → tarjeta roja con el mensaje clasificado + botón "Editar datos de conexión" (regresa al paso 2 con los valores ya llenos, excepto la contraseña) + enlace secundario "Guardar y probar más tarde" (→ `#/conexiones`).

### 9.10. Pantalla: Listado de conexiones (`#/conexiones`)
Tabla: nombre, tipo (local/remota, con ícono distinto), badge de `estado_conexion`, fecha de `ultima_prueba_en`, botón "Probar de nuevo" (→ 7.12), botón "Eliminar" (→ 7.15, con diálogo de confirmación; si el backend responde `CONEXION_EN_USO`, mostrar el mensaje exacto devuelto en vez de eliminar). Botón "+ Nueva conexión" → `#/conexiones/nueva`.

### 9.11. Pantalla: Listado de reportes (`#/reportes`)
- Campo `busqueda` (texto, debounce 300ms) y selector `tipo` (dropdown) → `GET /api/reportes` (7.16).
- Tabla: nombre, descripción (truncada a 80 caracteres), ícono de `tipo_visualizacion`, badge de nivel (nombre del rol equivalente, no el número crudo), fecha/estado de última ejecución, botón "Ejecutar" (→ `#/reportes/{id}/ejecutar`), y si `es_admin_tenant=true`: botones "Editar" y "Eliminar" adicionales.
- Botón "+ Nuevo reporte" (solo admin) → `#/reportes/nuevo`.

### 9.12. Pantalla: Ejecución de reporte (`#/reportes/{id}/ejecutar`)
Al cargar: `GET /api/reportes/{id}` (7.17, sin `sql_consulta` si no es admin). Formulario dinámico desde `parametros_json`: `texto`→input text, `numero`→input number, `fecha`→input date, `rango_fecha`→dos input date combinados en `"desde,hasta"`, `lista`→select con `opciones`; los `requerido=true` bloquean el envío vacío en cliente. **Envía:** `POST /api/reportes/{id}/ejecutar` (7.20). Mientras espera: spinner "Ejecutando consulta...". Resultado: tabla ordenable por columna (clic en encabezado, del lado del cliente) para `tabla`/`tabla_agrupada`; Chart.js (columna 0 = labels, resto = datasets) para los tipos de gráfico. Si `filas_truncadas=true`, aviso "Se muestran los primeros 5,000 resultados...". Botones "Exportar a Excel"/"Exportar a CSV" → 7.21, reenviando los mismos parámetros ya usados (guardados en el estado del frontend).

### 9.13. Pantalla: Editor de reportes (`#/reportes/nuevo`, `#/reportes/{id}/editar`)
- `connection_id` (dropdown, obligatorio) — solo conexiones con `estado_conexion='exitosa'` (7.14).
- `nombre` (texto, obligatorio), `descripcion` (textarea, opcional).
- `sql_consulta` (textarea con numeración de línea) + panel lateral colapsable con el esquema de la conexión (7.13).
- Botón "Validar consulta" → 7.18; autocompleta filas de parámetros a partir de `parametros_detectados`.
- `tipo_visualizacion` (dropdown, obligatorio).
- `nivel_minimo_requerido` (dropdown, obligatorio, poblado desde 7.23 mostrando nombre de rol, enviando su `nivel` numérico).
- Sección repetible "Parámetros": `nombre` (solo lectura si autodetectado), `etiqueta` (texto), `tipo` (dropdown), `requerido` (checkbox), `opciones` (textarea una por línea, solo si `tipo=lista`), `valor_default` (texto, opcional).
- **Envía:** `POST`/`PUT /api/reportes` (7.19). Error `VALIDACION_FALLIDA` por descoordinación de parámetros → resalta la sección de parámetros con el detalle exacto.

### 9.14. Pantalla: Gestión de usuarios (`#/admin/usuarios`, solo admin)
Tabla (7.24 GET): nombre, email, rol (dropdown editable inline → `PUT /api/usuarios/{id}/rol`), estado (badge), último login, botón activar/desactivar (→ `PUT .../estado`, con diálogo explicando el cierre de sesión forzado). Modal "Invitar usuario": `nombre_completo`, `email`, `rol_id` (dropdown) → `POST /api/usuarios/invitar`.

### 9.15. Pantalla: Gestión de roles (`#/admin/roles`, solo admin)
Tabla (7.23 GET): nombre, nivel, `cantidad_usuarios`, botón eliminar (deshabilitado con tooltip si `es_rol_por_defecto=true` o `cantidad_usuarios>0`). Formulario crear/editar: `nombre` (texto, obligatorio), `nivel` (numérico 0-100, obligatorio, con texto de ayuda explicando la jerarquía).

### 9.16. Panel de super-administración (`#/plataforma/tenants`, solo super-admin)
Tabla (7.25 GET): nombre, slug, estado (badge), plan, fecha de creación, `cantidad_usuarios`, `ejecuciones_ultimo_mes`. Botón suspender/reactivar → `PUT .../estado`, con diálogo de confirmación explicando el cierre inmediato de todas las sesiones del tenant.

---

## 10. Relaciones entre entidades y reglas de negocio transversales

```
tenants (1)──<(N) roles
tenants (1)──<(N) users
tenants (1)──<(N) tenant_connections
tenants (1)──<(N) reports
tenants (1)──<(N) query_execution_log

roles   (1)──<(N) users                    [users.rol_id]
users   (1)──<(N) reports                  [reports.creado_por]
users   (1)──<(N) tenant_connections       [tenant_connections.creado_por]
users   (1)──<(N) query_execution_log
users   (1)──<(N) sessions
users   (1)──<(N) auth_tokens
users   (1)──(0..1) platform_admins        [fuera de la jerarquia de cualquier tenant]

tenant_connections (1)──<(N) reports       [reports.connection_id, ON DELETE RESTRICT]
reports (1)──<(N) query_execution_log      [ON DELETE SET NULL]
```

**Por qué el acceso a reportes es un número y no una tabla de permisos N:M:** se usa `reports.nivel_minimo_requerido` (entero) comparado contra `roles.nivel` — una jerarquía ("nivel 50 ve todo lo de nivel ≤50"), no una asignación arbitraria rol-por-rol. Esto simplifica el modelo y la UI a cambio de no soportar "el rol X ve el reporte Y pero no el Z aunque compartan nivel". Si se necesita esa granularidad, requeriría una tabla `report_role_overrides` — **nota de diseño para una fase futura, no construir en el MVP.**

**Reglas que involucran más de una entidad:**
1. **Borrado de un rol:** bloqueado si `es_rol_por_defecto=true` o si tiene usuarios asignados (7.23). No bloqueado por `reports.nivel_minimo_requerido` (es un número suelto, no FK), pero la UI advierte del impacto.
2. **Borrado de una conexión:** `ON DELETE RESTRICT` en `reports.connection_id` — la API traduce el rechazo de MySQL en un mensaje de negocio legible (7.15).
3. **Desactivación de un usuario:** elimina sus `sessions` pero conserva `reports.creado_por` y `query_execution_log.user_id` intactos, para no perder historial de auditoría.
4. **Suspensión de un tenant:** elimina las `sessions` de todos sus usuarios en la misma transacción; además, `RequireTenantMatch` valida en cada request autenticado que el tenant siga `activo`, no solo que la sesión exista (doble verificación).
5. **Ejecución de un reporte:** toca simultáneamente `users→roles` (nivel), `reports` (definición y nivel mínimo), `tenant_connections` (credenciales) y `query_execution_log` (registro) — es la operación más sensible y la única que involucra las cuatro entidades en una sola solicitud.
6. **Cambio de estado de una conexión de `exitosa` a `fallida`** (por ejemplo, el cliente cambió su contraseña de MySQL en su hosting externo sin avisar): no afecta el registro de `reports` ya creados, que permanecen visibles en el listado, pero cualquier intento de `POST .../ejecutar` sobre ellos responde `CONEXION_FALLIDA` en vez de intentar conectar y fallar de forma menos controlada.

---

## 11. Catálogo completo de códigos de error

| Código | Situación | HTTP |
|---|---|---|
| `SLUG_NO_DISPONIBLE` | Slug de tenant ya existe | 409 |
| `VALIDACION_FALLIDA` | Datos de formulario inválidos | 400 |
| `TOKEN_INVALIDO_O_EXPIRADO` | Token de verificación/invitación/recuperación no válido | 400 |
| `TENANT_SUSPENDIDO` | Tenant no existe o no está activo | 403 |
| `CREDENCIALES_INVALIDAS` | Email o contraseña incorrectos | 401 |
| `CUENTA_BLOQUEADA_TEMPORALMENTE` | 5 intentos fallidos de login | 423 |
| `USUARIO_NO_ACTIVO` | Usuario invitado sin verificar o desactivado | 403 |
| `SESION_EXPIRADA` | Cookie de sesión inválida, vencida, o tenant ya no activo | 401 |
| `SOLO_LECTURA_VIOLADA` | La consulta contiene una sentencia de escritura | 422 |
| `CONEXION_FALLIDA` | No se pudo conectar a la base del tenant | 502 |
| `TIMEOUT_CONSULTA` | La consulta excedió el tiempo límite | 504 |
| `NIVEL_ACCESO_INSUFICIENTE` | Rol del usuario no alcanza el nivel del reporte | 403 |
| `TENANT_NO_COINCIDE` | Intento de acceder a un recurso de otro tenant | 403 |
| `LIMITE_TASA_EXCEDIDO` | Rate limit de ejecuciones alcanzado | 429 |
| `PARAMETRO_INVALIDO` | Un parámetro de ejecución no cumple su definición | 400 |
| `TIPO_VISUALIZACION_INCOMPATIBLE` | Resultado no compatible con el tipo de gráfico | 422 |
| `ROL_EN_USO` | Se intenta borrar un rol con usuarios asignados | 409 |
| `ROL_POR_DEFECTO_NO_ELIMINABLE` | Se intenta borrar el rol "Administrador" fundador | 409 |
| `CONEXION_EN_USO` | Se intenta borrar una conexión con reportes asociados | 409 |

---

## 12. Casos de uso detallados

Cada caso muestra la cadena completa: acción del usuario en la interfaz → llamada a la API → efecto en la base de datos → regla de negocio que se dispara → qué ve el usuario al final. Sirven para verificar que la interacción entre pantallas, endpoints y reglas de negocio (secciones 7, 9 y 10) es consistente entre sí.

### 12.1. Onboarding exitoso de un tenant nuevo

**Contexto:** Ana quiere crear una cuenta para su organización.

1. Ana abre `#/registro` (9.2) y escribe "Comunidad Ejemplo" en `nombre_organizacion`. El campo `slug` se autocompleta con `comunidad-ejemplo`; al perder el foco, el frontend dispara `GET /api/tenants/verificar-slug?slug=comunidad-ejemplo` (7.1) y muestra ✓ verde.
2. Ana completa el resto y envía. El frontend llama `POST /api/auth/registro-tenant` (7.2).
3. En el backend, dentro de una sola transacción: se revalida el `slug` (por si alguien más lo tomó entre el paso 1 y el envío — condición de carrera), se inserta `tenants` (`estado='pendiente_verificacion'`), se inserta el rol `Administrador` (`nivel=100`), se inserta el usuario Ana (`estado='invitado'`), y se genera un `auth_tokens` de tipo `verificacion_correo`.
4. Ana recibe el correo, hace clic, llega a `#/verificar-correo?token=...` (9.3), que dispara automáticamente `GET /api/auth/verificar-correo` (7.3). El backend marca el token usado, `users.estado='activo'` y `tenants.estado='activo'`.
5. Ana es redirigida a `#/login?tenant=comunidad-ejemplo` con el campo `slug_tenant` ya prellenado. Inicia sesión (7.4) y llega al Dashboard (9.8), que muestra `total_reportes_visibles: 0` porque todavía no configuró ninguna conexión ni reporte.

**Por qué importa:** demuestra que el `slug` se valida dos veces (UI en vivo + backend atómico) y que ningún usuario puede operar (ni siquiera hacer login) hasta completar la verificación — el estado `invitado`/`pendiente_verificacion` bloquea todo antes de tiempo.

### 12.2. Registro con slug ya tomado (fallo controlado)

1. Un segundo usuario, Carlos, intenta registrar su organización también como `comunidad-ejemplo` (quizás porque Ana lo compartió como ejemplo en una capacitación).
2. El chequeo en vivo (7.1) ya le muestra ✗ rojo antes de enviar el formulario, pero supongamos que Carlos tiene una pestaña vieja abierta y envía de todos modos.
3. El backend, en la transacción de `POST /api/auth/registro-tenant`, encuentra el conflicto y responde `SLUG_NO_DISPONIBLE` (409) **sin haber creado ninguna fila** (ni tenant, ni rol, ni usuario) — la transacción completa se revierte.
4. El frontend resalta el campo `slug` en rojo con el mensaje del backend; el resto de los datos que Carlos ya escribió (nombre, email, contraseña) permanecen en el formulario para que no tenga que reescribirlos.

**Por qué importa:** confirma que la operación de registro es atómica — un fallo a mitad de camino nunca deja un tenant "huérfano" sin su rol o su usuario administrador.

### 12.3. Conexión remota que falla por firewall, corregida después

1. Ana, ya autenticada, va a `#/conexiones/nueva` (9.9), elige "En un servidor externo" (tipo remota), copia el script de aprovisionamiento (8.2) y lo ejecuta en el phpMyAdmin de su otro proveedor de hosting. Llena host, puerto, `nombre_bd`, usuario y contraseña ya creados, y presiona "Probar conexión".
2. El frontend llama `POST /api/conexiones` (7.11, guarda con `estado_conexion='no_probada'`) y luego `POST /api/conexiones/{id}/probar` (7.12).
3. El proveedor externo de Ana todavía no ha añadido la IP del servidor cPanel a su whitelist de "Remote MySQL". PDO lanza `SQLSTATE[HY000] [2002]`. El backend clasifica el error, guarda `estado_conexion='fallida'` y `ultimo_error`, y responde con el mensaje "No se pudo alcanzar el servidor. Verifica el host y que el firewall permita conexiones remotas."
4. Ana ve la tarjeta roja, hace clic en "Guardar y probar más tarde" (no en "Editar datos de conexión", porque los datos están correctos, solo falta que su proveedor habilite el acceso) y navega a `#/conexiones` (9.10), donde ve la conexión listada con badge rojo "fallida".
5. Al día siguiente, tras contactar a su proveedor, Ana vuelve a `#/conexiones` y presiona "Probar de nuevo" sobre esa misma fila. Esta vez `SELECT 1` funciona, `estado_conexion` pasa a `exitosa`, y la conexión ahora aparece disponible como opción en el editor de reportes (9.13), donde antes no aparecía.

**Por qué importa:** muestra que una conexión fallida no es un callejón sin salida — queda guardada, visible, y reintentable, y su cambio de estado tiene un efecto inmediato y observable en otra pantalla (deja de estar oculta en el selector de reportes).

### 12.4. Creación y ejecución exitosa de un reporte parametrizado

1. Con la conexión ya en estado `exitosa`, Ana va a `#/reportes/nuevo` (9.13), selecciona la conexión, y escribe: `SELECT mes, SUM(total) AS total FROM ventas WHERE fecha BETWEEN :fecha_inicio AND :fecha_fin GROUP BY mes`.
2. Presiona "Validar consulta" → `POST /api/reportes/validar-consulta` (7.18). El backend ejecuta `QueryValidator` (pasa, porque empieza con `SELECT` y no contiene palabras prohibidas), luego `EXPLAIN` contra la base real de Ana, y detecta `columnas_detectadas: ["mes","total"]` y `parametros_detectados: ["fecha_inicio","fecha_fin"]`.
3. El frontend autocompleta dos filas en la sección de parámetros con `nombre=fecha_inicio` y `nombre=fecha_fin` ya fijos (de solo lectura); Ana solo completa `etiqueta="Desde"/"Hasta"`, `tipo=fecha` y marca ambos como `requerido`.
4. Ana fija `tipo_visualizacion=grafico_barra` y `nivel_minimo_requerido` = nivel del rol "Coordinador" (50), y guarda → `POST /api/reportes` (7.19). El backend re-verifica que los placeholders coincidan exactamente con los parámetros declarados (coinciden, así que no hay `VALIDACION_FALLIDA`) y crea la fila en `reports`.
5. Un usuario con rol "Coordinador" (nivel 50) entra a `#/reportes` (9.11), ve este reporte en su listado (porque `50 <= 50`), hace clic en "Ejecutar", llena el formulario de fechas generado dinámicamente a partir de `parametros_json`, y envía → `POST /api/reportes/{id}/ejecutar` (7.20).
6. El backend valida el nivel (pasa), valida los parámetros como fechas bien formadas (pasa), abre la conexión dinámica descifrando las credenciales de Ana, ejecuta con `bindValue()` para ambas fechas, y como el resultado tiene 2 columnas con la primera como etiqueta, es compatible con `grafico_barra`. Inserta la fila en `query_execution_log` y responde con `columnas`, `filas` y `duracion_ms`.
7. El frontend dibuja un gráfico de barras con Chart.js: eje X = meses, altura de barra = total.

**Por qué importa:** conecta seis piezas distintas (validación de solo-lectura, detección automática de parámetros, coincidencia parámetro↔placeholder, verificación de nivel de acceso, ejecución parametrizada segura, y compatibilidad de la forma del resultado con el tipo de gráfico elegido) en un solo flujo de punta a punta.

### 12.5. Intento de inyectar una sentencia de escritura disfrazada

1. Un usuario malicioso con acceso de administrador de otro tenant (o un colaborador descuidado) escribe en el editor de reportes: `SELECT * FROM productos; DROP TABLE productos;--`.
2. Al presionar "Validar consulta" (7.18), el `QueryValidator` (8.1) primero elimina comentarios (el `--` final), luego detecta que hay más de una sentencia separada por `;`, y rechaza la consulta con `SOLO_LECTURA_VIOLADA` **antes** de ejecutar ningún `EXPLAIN` contra la base real — la consulta nunca llega a tocar la conexión del tenant.
3. Aunque el `QueryValidator` fallara por algún caso no previsto, la segunda capa de defensa seguiría intacta: el usuario MySQL asociado a esa conexión (8.2) solo tiene `GRANT SELECT`, por lo que un `DROP TABLE` sería rechazado también por el propio motor MySQL con un error de permisos, no porque la aplicación lo haya "adivinado" correctamente.

**Por qué importa:** ilustra por qué el documento insiste en que la validación textual es una capa adicional y no la única defensa — muestra el caso donde, incluso si una fallara, la otra seguiría bloqueando el daño real.

### 12.6. Usuario con nivel insuficiente intenta acceder a un reporte

1. Un usuario con rol "Operador" (nivel 10) recibe por WhatsApp, de un colega, el enlace directo `#/reportes/8/ejecutar`, donde el reporte 8 tiene `nivel_minimo_requerido=50`.
2. El listado de `#/reportes` (7.16) nunca le mostró este reporte (el `WHERE nivel_minimo_requerido<=10` lo excluyó), pero al llegar directo a la URL, el frontend igual llama `GET /api/reportes/8` (7.17) para cargar la definición.
3. El backend responde `NIVEL_ACCESO_INSUFICIENTE` (403) — la verificación de nivel ocurre en el propio endpoint de detalle, no solo en el de ejecución, así que el usuario ni siquiera ve el nombre o la descripción del reporte, solo un mensaje de acceso denegado.

**Por qué importa:** confirma que el control de acceso por nivel no depende de que el frontend "esconda" el botón — se re-verifica en cada endpoint relevante, incluyendo la simple carga de la definición, no solo la ejecución.

### 12.7. Cambio de rol en caliente afecta visibilidad inmediata

1. Un usuario, Luis, tiene rol "Coordinador" (nivel 50) y ve 6 reportes en su listado.
2. Ana, como admin, entra a `#/admin/usuarios` (9.14) y cambia el rol de Luis a "Operador" (nivel 10) mediante el dropdown inline → `PUT /api/usuarios/{id}/rol` (7.24).
3. Luis, que tiene su sesión de `#/reportes` abierta en otra pestaña sin refrescar, presiona F5. La siguiente llamada a `GET /api/reportes` ya usa su nuevo `rol.nivel=10` (leído de `users.rol_id → roles.nivel` en cada request, nunca cacheado en la sesión) y ahora solo ve 2 reportes.
4. Si Luis, sin refrescar, intenta ejecutar uno de los 4 reportes que ya no debería ver (por ejemplo, si tenía la pestaña de ejecución ya abierta desde antes del cambio), `POST /api/reportes/{id}/ejecutar` igual responde `NIVEL_ACCESO_INSUFICIENTE`, porque la verificación de nivel se hace en cada ejecución, no una sola vez al cargar la página.

**Por qué importa:** deja claro que el nivel de acceso nunca se calcula una sola vez al iniciar sesión y se guarda — se recalcula en cada request, así que un cambio de rol tiene efecto inmediato sin necesidad de que el usuario afectado cierre sesión.

### 12.8. Invitación que expira sin ser aceptada

1. Ana invita a un nuevo usuario, Pedro, el 1 de julio → `POST /api/usuarios/invitar` (7.24) crea `users` (`estado='invitado'`) y un `auth_tokens` (`tipo='invitacion'`, `expira_en = 1 de julio + 7 días = 8 de julio`).
2. Pedro no revisa su correo a tiempo. El 10 de julio hace clic en el enlace de invitación, llega a `#/aceptar-invitacion?token=...` (9.7), llena su nueva contraseña y envía → `POST /api/auth/aceptar-invitacion` (7.9).
3. El backend encuentra el token pero `expira_en < NOW()`, y responde `TOKEN_INVALIDO_O_EXPIRADO`. El frontend muestra "Este enlace ya no es válido".
4. Como el MVP no incluye un endpoint de reenvío de invitación, Ana debe notar que Pedro sigue en estado `invitado` en `#/admin/usuarios` y volver a invitarlo desde cero con `POST /api/usuarios/invitar`, lo cual genera un nuevo `auth_tokens` con una nueva fecha de expiración (el token anterior queda simplemente vencido e inutilizable, no se reactiva).

**Por qué importa:** deja explícito qué pasa en el caso, nada raro, de una invitación vencida — y documenta la ausencia deliberada de un endpoint de "reenviar invitación" en el MVP, para que la IA constructora no lo omita por descuido sino porque así se decidió.

### 12.9. Intento de borrar una conexión con reportes asociados

1. Ana intenta eliminar la conexión "Base principal" desde `#/conexiones` (9.10), sin darse cuenta de que tiene 3 reportes construidos sobre ella.
2. El frontend llama `DELETE /api/conexiones/{id}` (7.15). En la base de datos, `reports.connection_id` tiene `ON DELETE RESTRICT` hacia `tenant_connections`, así que MySQL rechaza la operación con un error de integridad referencial.
3. El backend captura ese error de MySQL (no lo deja pasar como un error 500 genérico) y lo traduce a `{"codigo":"CONEXION_EN_USO","mensaje":"No se puede eliminar: existen 3 reportes usando esta conexión."}` (409).
4. Ana ve el mensaje exacto, entiende que debe ir primero a `#/reportes`, filtrar por esa conexión (funcionalidad implícita: el editor permite ver a qué conexión pertenece cada reporte) y eliminar o reasignar esos 3 reportes antes de poder borrar la conexión.

**Por qué importa:** muestra por qué el catálogo de errores (sección 11) exige traducir errores de base de datos en mensajes de negocio legibles, en vez de dejar que el usuario final vea un mensaje técnico de MySQL.

### 12.10. Suspensión de un tenant con usuarios conectados en ese momento

1. El super-admin de la plataforma nota, en `#/plataforma/tenants` (9.16), que "Comunidad Ejemplo" no ha pagado su plan y decide suspenderla → `PUT /api/plataforma/tenants/{id}/estado` (7.25) con `{"estado":"suspendido"}`.
2. En una sola transacción, el backend cambia `tenants.estado='suspendido'` y elimina **todas** las filas de `sessions` cuyo `tenant_id` corresponda — incluyendo la de Luis, que en ese momento tenía el Dashboard abierto.
3. La siguiente acción de Luis en la interfaz (por ejemplo, hacer clic en "Reportes") dispara una llamada a la API que pasa por el middleware `RequireAuth`; este ya no encuentra la fila de `sessions` (fue borrada) y responde `SESION_EXPIRADA`. El frontend lo redirige a `#/login`.
4. Si Luis intenta iniciar sesión de nuevo con su email y contraseña correctos, `POST /api/auth/login` (7.4) encuentra que `tenants.estado != 'activo'` y responde `TENANT_SUSPENDIDO` antes incluso de verificar su contraseña.

**Por qué importa:** confirma que "suspender un tenant" no es solo una bandera decorativa en una tabla — tiene un efecto inmediato y verificable en cualquier sesión activa, sin esperar a que expire por tiempo.

---

## 13. Plan de fases

1. **Núcleo:** modelo de datos, autenticación completa (registro, verificación, login, recuperación, invitaciones), RBAC, CRUD de conexiones con cifrado y prueba en vivo.
2. **Motor de consultas:** `QueryValidator`, `QueryExecutor`, introspección de esquema, validación de consulta.
3. **Reportes:** CRUD, ejecución con parámetros, visualización en tabla.
4. **Visualización avanzada:** gráficos, exportación CSV/Excel, Dashboard.
5. **Administración y pulido:** panel de super-admin, auditoría visible, mejoras de UI.

---

## 14. Criterios de aceptación

- [ ] Flujo completo de registro → verificación → login funciona sin intervención del super-admin.
- [ ] Un usuario invitado no puede hacer login hasta aceptar su invitación y fijar contraseña.
- [ ] Recuperar contraseña nunca revela si un correo existe o no, y al completarse cierra todas las sesiones previas del usuario.
- [ ] `DELETE FROM cualquier_tabla` es rechazado con `SOLO_LECTURA_VIOLADA` sin tocar la base real.
- [ ] Un usuario del tenant A no accede a nada del tenant B ni manipulando IDs en la URL.
- [ ] Un rol de nivel 10 no ve ni puede ejecutar un reporte con `nivel_minimo_requerido=50`.
- [ ] Toda ejecución (éxito o fallo) queda en `query_execution_log` con `duracion_ms` y `filas_retornadas`.
- [ ] Las credenciales de conexión nunca aparecen en texto plano en ninguna respuesta ni log.
- [ ] Una consulta de más de 10 segundos se cancela con `TIMEOUT_CONSULTA`.
- [ ] Eliminar una conexión con reportes asociados devuelve `CONEXION_EN_USO` con la cantidad exacta afectada.
- [ ] Desactivar un usuario o suspender un tenant cierra sus sesiones activas de inmediato.
- [ ] Un reporte `grafico_barra` con una sola columna de resultado falla con `TIPO_VISUALIZACION_INCOMPATIBLE` en vez de romper el frontend.
- [ ] Un usuario no-admin que consulta la definición de un reporte no recibe el campo `sql_consulta` en la respuesta.
- [ ] Cambiar la contraseña de origen de una conexión remota sin actualizarla en la plataforma hace que sus reportes fallen con `CONEXION_FALLIDA` de forma controlada, no con un error no manejado.
