# 07 - HTTP API y motor de reglas de alerta

Este documento cubre las nuevas piezas SaaS-ready añadidas al sistema:

1. Autenticación por **API Key** para integraciones máquina-a-máquina.
2. Endpoint REST **`POST /?c=api&a=ingest`** para ingesta JSON de lecturas.
3. Endpoint protegido **`/?c=sincronizacion&a=importarLecturas`** (CSV legacy).
4. Motor de **reglas de alerta configurables** (`reglas_alerta`) y cron consumidor.

---

## 1. API Keys

### Modelo de datos
Tabla `api_keys` (migración `2026_04_20_000012_create_api_keys_table`).

| Columna | Descripción |
|---|---|
| `Nombre` | Identificación humana de la integración |
| `KeyPrefix` | 8 chars en claro para reconocer la clave en logs |
| `KeyHash` | SHA-256 de la clave completa (lo único que se guarda) |
| `Scopes` | CSV de scopes autorizados (`ingest`, `read`, `*`) |
| `AllowedIps` | CSV opcional de IPs/CIDR permitidos |
| `IdEstacion` | Si se define, fija la estación destino de la clave |
| `RateLimitPorMinuto` | Default 120 req/min por clave |
| `Estado`, `FechaExpiracion` | Revocación / expiración |

### CLI

```bash
# Crear (la clave en claro solo se muestra esta vez)
php bin/api-key create --name="Cron EST1" --scopes=ingest --estacion=1 --ips=127.0.0.1

# Listar
php bin/api-key list

# Revocar
php bin/api-key revoke --id=3
```

### Uso en HTTP

Cualquiera de estas formas funciona:

```
X-Api-Key: klee_<prefix>_<secret>
Authorization: Bearer klee_<prefix>_<secret>
?api_key=klee_<prefix>_<secret>          # solo recomendable en pruebas
```

Validaciones aplicadas por `core/ApiAuth.php`:

1. Clave existe y `Estado=1`.
2. No expirada (`FechaExpiracion`).
3. Scope solicitado está en `Scopes`.
4. IP cliente está en `AllowedIps` (si está definido). Soporta CIDR IPv4.
5. Rate limit por clave (`RateLimitPorMinuto`).

Errores de auth devuelven JSON:

```json
{ "error": true, "status": 401, "message": "API key inválida o revocada" }
```

---

## 2. Endpoint `POST /?c=api&a=ingest`

Ingesta JSON en batch para sensores. Procesado por `core/IngestService.php`.

### Headers
- `X-Api-Key: klee_...` (scope `ingest`)
- `Content-Type: application/json`

### Body

```json
{
  "estacion_id": 5,
  "readings": [
    {
      "sensor_codigo": "TEMP1",
      "ts": "2026-04-20T12:34:56Z",
      "value": 23.4,
      "unidad": "°C",
      "variable": "Temperatura"
    },
    {
      "sensor_codigo": "BATT",
      "ts": "2026-04-20T12:34:56Z",
      "value": 12.1
    }
  ]
}
```

Reglas de resolución de la estación (orden de precedencia):
1. `IdEstacion` de la API key (si está vinculada a una estación).
2. `estacion_id` del body.
3. `estacion_codigo` del body (busca en tabla `estaciones`).

### Comportamiento
- Si `sensor_codigo` no existe para la estación, se **auto-crea** el sensor (campos `nombre`, `tipo`, `variable`, `unidad` opcionales).
- Lecturas duplicadas (`IdEstacion+IdSensor+Fecha+Hora`) se descartan vía `INSERT IGNORE`.
- Tamaño máximo del batch: **5000 lecturas** por request.

### Respuesta

```json
{
  "ok": true,
  "received": 2,
  "inserted": 2,
  "duplicates": 0,
  "errors": []
}
```

### Ejemplo cURL

```bash
curl -X POST "http://localhost/?c=api&a=ingest" \
  -H "X-Api-Key: klee_xxxxxxxx_yyyy..." \
  -H "Content-Type: application/json" \
  -d '{"estacion_id":1,"readings":[{"sensor_codigo":"TEMP1","ts":"2026-04-20T12:00:00Z","value":23.4,"unidad":"C"}]}'
```

---

## 3. Endpoint `/?c=sincronizacion&a=importarLecturas` (CSV legacy)

Sigue existiendo para procesar archivos en `storage/lecturas/` y `storage/pendientes/`, pero ahora **requiere API key con scope `ingest`**. El acceso público anterior fue removido.

```bash
curl "http://localhost/?c=sincronizacion&a=importarLecturas&IdEstacion=1" \
  -H "X-Api-Key: klee_xxxxxxxx_yyyy..."
```

Si la API key tiene `IdEstacion` definida, el parámetro `IdEstacion` del request se ignora y se fuerza el de la clave (defensa contra cross-tenant).

---

## 4. Motor de reglas de alerta

### Tabla `reglas_alerta`

Migración `2026_04_20_000013_create_reglas_alerta_table`. Reemplaza la lógica hard-coded del cron.

| Columna | Descripción |
|---|---|
| `TipoRegla` | `UMBRAL_ALTO` / `UMBRAL_BAJO` / `FUERA_DE_RANGO` / `SIN_COMUNICACION` |
| `CodigoAlerta` | Dedupe key escrita en `sensores_alertas.TipoAlerta` (ej. `BATERIA_BAJA`) |
| `Severidad` | `info` / `warning` / `critical` |
| `IdSensor` | Si está, regla aplica solo a ese sensor |
| `IdEstacion` | Si está (y `IdSensor` está vacío), aplica a todos los sensores de la estación |
| `VariableLike` | Filtro adicional por substring en `Variable`/`Codigo`/`Nombre` (ej. `%bater%`) |
| `UmbralAlto`, `UmbralBajo` | Umbrales de la regla (decimales) |
| `VentanaMinutos` | Para `SIN_COMUNICACION`: minutos sin lectura antes de disparar |
| `MensajePlantilla` | Texto con `{placeholders}` |
| `Estado`, `Prioridad` | Activa/inactiva, orden de evaluación (menor primero) |

### Placeholders soportados en `MensajePlantilla`

- `{sensor_nombre}`, `{sensor_codigo}`
- `{valor}`, `{unidad}`
- `{umbral}`, `{umbral_alto}`, `{umbral_bajo}`
- `{ventana}`, `{minutos_sin_datos}`, `{ultima_lectura}` (solo `SIN_COMUNICACION`)

### Reglas semilla creadas

| Nombre | Tipo | Filtro | Umbral | Ventana |
|---|---|---|---|---|
| Sin comunicación 60min (global) | `SIN_COMUNICACION` | — | — | 60 min |
| Batería baja | `UMBRAL_BAJO` | `%bater%` | `sensores.UmbralAlerta` (fallback) | — |
| Umbral alto (genérico) | `UMBRAL_ALTO` | — | `sensores.UmbralAlerta` (fallback) | — |

> **Compatibilidad:** si una regla de umbral no define `UmbralAlto`/`UmbralBajo`, el cron usa `sensores.UmbralAlerta` como fallback. Esto preserva el comportamiento previo sin requerir migrar valores.

### Cron

```bash
php bin/cron_sensores_alertas.php
```

El script:
1. Carga todas las reglas con `Estado=1`.
2. Carga todos los sensores activos con su última lectura.
3. Para cada sensor, evalúa todas las reglas que apliquen (ámbito + `VariableLike`).
4. Inserta en `sensores_alertas` con dedupe por `(IdSensor, CodigoAlerta, Fecha, Hora)` y por alerta activa equivalente.
5. Cierra alertas activas cuando la condición se normaliza.

Salida típica:

```
Reglas activas: 3
Sensores evaluados: 12
Alertas creadas: 1
Alertas sin comunicación cerradas: 0
Alertas por umbral cerradas: 2
```

### Crear una regla específica

```sql
INSERT INTO reglas_alerta
  (Nombre, TipoRegla, CodigoAlerta, Severidad, IdSensor, UmbralAlto, MensajePlantilla, Estado, Prioridad, FechaRegistro)
VALUES
  ('Temp crítica sensor 7', 'UMBRAL_ALTO', 'TEMP_CRITICA', 'critical',
   7, 35.0,
   'Temperatura crítica en {sensor_nombre}: {valor}{unidad} (>{umbral})',
   1, 5, NOW());
```

---

## 5. Próximos pasos sugeridos

- Webhooks salientes en `Alert Manager` (suscribir URL por tenant).
- Escalamiento automático si una alerta no es atendida en X minutos.
- Endpoint `GET /?c=api&a=lecturas` con paginación para consumo desde dashboards externos.
- Reemplazar `MyISAM` en tablas núcleo (`estaciones`, `sensores`, `lecturas`) por InnoDB con FKs.
