# Despliegue

> **Leer antes de desplegar.** Esta versión **no arranca sin un archivo
> `.env`**. La `SECRET_KEY`, las credenciales de MySQL y las de Twilio estaban
> escritas en el código y ahora son obligatorias por entorno. Si se despliega
> sin crear el `.env`, el proceso falla al arrancar con un
> `ImproperlyConfigured` explicando qué falta.

---

## 1. Antes de subir nada

### Rotar las credenciales expuestas

Todas estas estuvieron en el repositorio y siguen en el historial de git.
Sacarlas del código **no las protege**: cualquiera con acceso al repositorio,
presente o pasado, las recupera con `git log -p`.

| Credencial | Dónde estaba | Cómo rotarla |
|---|---|---|
| `SECRET_KEY` de Django | `core/settings.py` | Generar una nueva (ver abajo). Invalida las sesiones del admin. |
| Contraseña de MySQL | `core/settings.py` | Cambiarla en el servidor y actualizar `DATABASE_URL`. |
| `auth_token` de Twilio | `notifications/utils/twilio.py` | Consola de Twilio → Account → API keys & tokens → rotar el auth token primario. |
| API key de `api-mails` | `notifications/utils/mails.py` (eliminado) | Revocar ese cliente en `api-mails`. Ya no se usa. |

Generar la clave secreta:

```bash
python -c "from django.core.management.utils import get_random_secret_key as k; print(k())"
```

### Sobre las claves de API

Las claves se guardaban en claro en la columna `keys_apikeys.key`. La
migración las convierte a hash **conservando el formato**, así que
**los clientes siguen usando exactamente la misma clave que ya tienen**: no
hay que avisar a nadie ni coordinar un cambio.

Lo que sí conviene asumir: esas claves estuvieron sin cifrar en la base de
datos, en las copias de seguridad y en el panel de administración. El hasheo
protege de aquí en adelante, no de lo que ya pudo copiarse. Rotarlas cuando
se pueda:

```bash
python manage.py create_api_key --rotate <prefijo>
```

Si alguna clave tiene un formato distinto de `ApiKey_<32 hex>_<32 hex>`, la
migración la **desactiva** y lo avisa por consola. Habrá que emitir una nueva:

```bash
python manage.py create_api_key --account "Nombre de la cuenta"
```

### Alcances: qué puede hacer cada clave

Hasta ahora una clave válida servía para todo: enviar por los tres canales y
consultar cualquier envío. Ahora cada clave lleva una lista de alcances.

| Alcance | Permite |
|---|---|
| `mail:send` | Enviar correo |
| `sms:send` | Enviar SMS |
| `whatsapp:send` | Enviar WhatsApp |
| `messages:read` | Consultar el estado de los envíos |
| `*` | Todo |

**Las claves que ya existen reciben los cuatro alcances en la migración**, que
es exactamente lo que podían hacer antes: nadie pierde acceso y nadie gana
permisos nuevos. Restringirlas es una decisión posterior, cuando se sepa qué
sistema usa cada una:

```bash
python manage.py create_api_key --set-scopes <prefijo> sms:send
```

Y para emitir una ya restringida:

```bash
python manage.py create_api_key --account "Cobranza" --scopes sms:send messages:read
```

Merece la pena hacerlo con las claves que solo mandan SMS o WhatsApp: mientras
tengan `mail:send`, sirven para enviar correo firmado por el dominio de la
empresa.

### Remitentes por cliente

Una clave puede limitarse a determinadas direcciones o dominios de remite:

```bash
python manage.py create_api_key --account "Cobranza" \
    --allowed-senders cobranza@zurco.com.mx
```

Si la lista está vacía se aplica la política global (`EMAIL_ALLOWED_FROM_DOMAINS`
y `EMAIL_ALLOWED_FROM_ADDRESSES`). Con ella, esa clave no puede enviar como
`direccion@` ni `nomina@` aunque el dominio esté autorizado en la global.

### Comprobar el estado de las cuentas

`Account.active` tiene `default=False` y hasta ahora **nunca se validaba**, así
que es probable que existan cuentas con `active=False` cuyas claves sí
funcionan. Antes de activar esa comprobación (pendiente para la fase de
unificación), revisar:

```sql
SELECT a.id, a.name, a.active, COUNT(k.id) AS claves_activas
FROM authentication_account a
JOIN keys_apikeys k ON k.account_id = a.id AND k.active = 1
GROUP BY a.id, a.name, a.active
HAVING a.active = 0;
```

Si devuelve filas, esas cuentas hay que activarlas o revocar sus claves.

**Ya no hace falta hacerlo a mano.** `Account.active` no se comprobaba en
ningún punto de la autenticación —desactivar una cuenta no cortaba nada—, y
ahora sí se comprueba. Para que activar esa comprobación no deje fuera a
clientes que hoy funcionan, la migración `keys` 0008 pone `active=True` en las
cuentas que ya tienen al menos una clave activa y sin revocar. El valor
anterior de esa columna no reflejaba ninguna decisión: la mayoría estaba a
`False` simplemente porque nadie la tocó.

A partir de este despliegue, desactivar una cuenta **sí** corta el acceso de
todas sus claves a la vez.

---

## 2. Crear el `.env` en el servidor

```bash
cp .env.example .env
chmod 600 .env
```

Rellenar como mínimo:

```env
DJANGO_SECRET_KEY=<la generada arriba>
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=notificaciones.zurco.com.mx
DJANGO_CSRF_TRUSTED_ORIGINS=https://notificaciones.zurco.com.mx

DATABASE_URL=mysql://usuario:password@host:3306/zurcocom_notificaciones_api

NUM_PROXIES=1

# Autenticacion
# Vida de los tokens de acceso, en segundos. Antes no caducaban.
ACCESS_TOKEN_TTL_SECONDS=300

# Canal de correo
EMAIL_ALLOWED_FROM_DOMAINS=zurco.com.mx
EMAIL_DEFAULT_FROM=zurcodesignionotificaciones@gmail.com
EMAIL_DEFAULT_FROM_NAME=Notificaciones Zurco
EMAIL_DEFAULT_REPLY_TO=support@zurco.com.mx
SENDGRID_API_KEY=SG....

# Canales SMS y WhatsApp
TWILIO_ACCOUNT_SID=AC...
TWILIO_AUTH_TOKEN=<el nuevo, tras rotar>
TWILIO_SMS_FROM=+17625502377
TWILIO_WHATSAPP_FROM=+5215631087771
TWILIO_MESSAGING_SERVICE_SID=MG...
```

`NUM_PROXIES` tiene que ser exacto. Si se pone `0` habiendo un proxy delante,
todo el tráfico parece venir de la misma IP y los límites de peticiones dejan
de servir. Si se pone de más, un cliente puede falsear su IP añadiendo saltos a
`X-Forwarded-For`.

### Si todavía no hay Redis

Se puede desplegar sin él, con dos consecuencias que hay que conocer:

```env
CELERY_TASK_ALWAYS_EAGER=True   # el envío ocurre dentro de la petición HTTP
REDIS_CACHE_URL=                # los límites se cuentan por proceso
```

Con `CELERY_TASK_ALWAYS_EAGER=True` no hace falta worker, pero se pierde el
reintento automático y la petición vuelve a esperar al proveedor. Es un
escalón intermedio válido; conviene poner Redis después.

---

## 3. Desplegar

```bash
pip install -r requirements.txt
python manage.py migrate
python manage.py collectstatic --noinput
python manage.py check --deploy     # debe salir sin avisos
```

Reiniciar el proceso. En cPanel con Passenger, tocar `passenger_wsgi.py` o
usar «Restart» en la interfaz de la aplicación Python.

### Con Redis y worker

```bash
celery -A core worker --loglevel=info --concurrency=4
celery -A core beat   --loglevel=info      # purgas periódicas
```

Dos tareas de mantenimiento, para `beat` o para un cron diario:

| Tarea | Qué hace |
|---|---|
| `notifications.purge_old_messages` | Borra la auditoría más antigua que `NOTIFICATIONS_RETENTION_DAYS` |
| `notifications.purge_expired_tokens` | Borra los tokens de acceso caducados |

La segunda es higiene, no seguridad: un token caducado ya no autentica, pero
los que se emiten y nunca se usan se acumulan.

---

## 4. Configurar los webhooks

### SendGrid

1. SendGrid → Settings → Mail Settings → Event Webhook.
2. URL: `https://notificaciones.zurco.com.mx/notifications/v1/webhooks/sendgrid`
3. Activar **Signed Event Webhook Requests** y copiar la clave pública a
   `SENDGRID_WEBHOOK_PUBLIC_KEY`.

Sin esa clave el endpoint **rechaza todos los eventos**. Es deliberado:
aceptarlos sin firma dejaría la auditoría abierta a cualquiera en internet.

También conviene autenticar el dominio en SendGrid (Sender Authentication →
Authenticate Your Domain). Sin SPF y DKIM el correo acaba en no deseado.

### Twilio

No hay que configurar nada en la consola de Twilio: la URL de callback viaja
en cada envío, construida a partir de `TWILIO_STATUS_CALLBACK_BASE_URL`. Basta
con que esa variable apunte a la URL pública del servicio.

Si se deja vacía, los envíos salen igual pero la auditoría solo sabrá que
Twilio aceptó el mensaje, no si llegó al teléfono.

La firma se valida con el `TWILIO_AUTH_TOKEN`, así que **al rotarlo hay que
desplegar el nuevo valor**: mientras no coincida, los avisos de estado se
rechazan con 401 (los envíos siguen funcionando).

---

## 5. Comprobar

```bash
curl -fsS https://notificaciones.zurco.com.mx/notifications/v1/health
# {"status":"ok"}

curl -fsS https://notificaciones.zurco.com.mx/notifications/v1/health/ready
# {"status":"ok","checks":{"database":true,"cache":true}}
```

Flujo completo:

```bash
TOKEN=$(curl -s https://notificaciones.zurco.com.mx/auth/v1/token/ \
  -H "X-API-Key: $API_KEY" | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")

curl -i https://notificaciones.zurco.com.mx/notifications/v1/mail/send/template \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"to":["tu-correo@ejemplo.com"],"to_cco":[],"system":"Prueba","notification":"El folio <b>ABC-123</b> se creó."}'
```

Los tokens son de un solo uso: hay que pedir uno nuevo para cada envío. Eso no
ha cambiado.

---

## 6. Volver atrás

**Haz una copia de seguridad de la base de datos antes de migrar.** No es la
recomendación genérica de siempre: aquí hay un paso que no se puede deshacer.

| Migración | ¿Reversible? |
|---|---|
| `notifications` 0007–0009 | Sí. Solo **añaden** tablas y ajustan `Numbers`; no tocan datos existentes. |
| `keys` 0003 | Sí. Solo añade columnas. |
| `keys` 0004–0005 | **No.** Convierten las claves a hash y eliminan la columna `key`. El secreto original no se guarda en ninguna parte: es justo el objetivo. |
| `keys` 0006–0007 | Sí. Añaden `scopes`, `allowed_senders` y la tabla de tokens, y conceden los alcances heredados. |
| `keys` 0008 | Sí, pero no deshace nada: activa las cuentas que ya tenían claves en uso. Revertirla es inofensivo porque el código anterior no mira ese campo. |

`keys` 0004 se niega explícitamente a revertirse en lugar de dejar la tabla en
un estado inconsistente en silencio.

**Revertir solo el código** (sin tocar el esquema) funciona para las
migraciones de `notifications`, pero **no** para las de `keys`: el código
anterior busca la columna `key`, que ya no existe, y toda la autenticación
falla. Si hay que volver atrás después de aplicar `keys` 0005, las opciones
son restaurar la copia de seguridad o quedarse en la versión nueva y arreglar
hacia delante.

Para deshacer solo el esquema de notificaciones:

```bash
python manage.py migrate notifications 0006
```

> **Esto borra la auditoría.** Las migraciones de `notifications` son
> reversibles en el sentido de que el comando termina sin error, pero
> deshacerlas **elimina las tablas** `notification_message`,
> `notification_event` y `notification_payload_blob` con todo su contenido: el
> histórico completo de envíos, no solo el esquema. Haz copia de seguridad
> antes, y no uses este comando para «probar» un rollback en producción.

---

## Qué cambia para los clientes

**Nada en los endpoints que ya usaban.** `/mail/send/template`, `/sms/send`,
`/whatsapp/send` y `/whatsapp/send/template` conservan el mismo contrato.

### Los tokens en vuelo dejan de valer al desplegar

El token pasa a colgar de la clave que lo pidió, en lugar del usuario. Los
tokens emitidos antes del despliegue dejan de funcionar en ese momento: el
cliente recibe un `401` y basta con que vuelva a pedir uno, que es lo que ya
hace en cada envío. Como son de un solo uso y de vida corta, la ventana es de
segundos, pero conviene desplegar fuera de un pico de tráfico.

Dos cosas más sobre el token:

* **Ahora caduca** (`ACCESS_TOKEN_TTL_SECONDS`, 300 s por defecto). Antes, uno
  emitido y nunca usado valía indefinidamente.
* **Revocar o rotar una clave invalida sus tokens** en el acto. Antes el token
  sobrevivía a la revocación hasta que alguien lo gastaba.

La respuesta de `/auth/v1/token/` añade `expires_at` y `scopes` a los campos
que ya devolvía. Los clientes actuales solo leen `token`, así que no les
afecta.

La tabla `authtoken_token` queda sin uso. No la borra ninguna migración; se
puede eliminar a mano cuando se haya comprobado que todo va bien.

Lo que cambia por debajo del endpoint de correo: antes hacía dos peticiones
HTTP a `api-mails.zurco.com.mx` (una para pedir token y otra para enviar), con
una clave escrita en el código. Ahora compone y envía directamente. Se va una
latencia completa, un punto de falla y una credencial que no se podía rotar sin
desplegar.

Endpoints nuevos:

| Método | Ruta | Descripción |
|---|---|---|
| `POST` | `/notifications/v1/mail/send` | Correo con control completo: remitente, adjuntos, plantillas de SendGrid |
| `GET` | `/notifications/v1/messages/<uuid>` | Estado de cualquier envío y sus eventos de entrega |
| `GET` | `/notifications/v1/mail/<uuid>` | Igual, restringido a correo (compatibilidad) |
| `POST` | `/notifications/v1/webhooks/sendgrid` | Eventos de entrega de correo |
| `POST` | `/notifications/v1/webhooks/twilio/<uuid>` | Estado de SMS y WhatsApp |
| `GET` | `/notifications/v1/health` | Comprobación de vida |
| `GET` | `/notifications/v1/health/ready` | Comprobación de dependencias |

### Aislamiento entre clientes

`GET /messages/<uuid>` y `GET /mail/<uuid>` devolvían **cualquier** envío a
**cualquier** cliente autenticado. Bastaba con tener el identificador, que
viaja en la respuesta del envío, en los webhooks y en los registros. Se
exponían asunto, destinatarios, remitente y, con `NOTIFICATIONS_STORE_BODY`
activo, los cuerpos.

Ahora cada cliente ve solo los envíos de su cuenta; el resto responde `404`,
no `403`, para no confirmar que el identificador existe. El filtro es por
cuenta y no por clave, así que rotar una credencial no esconde el historial
anterior.

### Cambios de comportamiento en SMS y WhatsApp

Los campos de entrada son los mismos, pero ahora se validan:

* **Los números deben ir en E.164** (`+525512345678`). Se aceptan espacios,
  guiones y paréntesis, que se eliminan. Un número sin código de país se
  rechaza con un 400 que dice cuál es, en vez de llegar a Twilio y volver como
  un genérico «Invalid data». Si prefieres que se complete solo, pon
  `DEFAULT_PHONE_COUNTRY_CODE=+52`.
* **La respuesta cambió de forma.** Antes era `{"status": true, "sids": [...]}`.
  Ahora informa del resultado por destinatario:

  ```json
  {
    "channel": "sms",
    "requested": 2, "queued": 0, "sent": 1, "failed": 1,
    "messages": [
      {"message_id": "…", "to": "+525512345671", "status": "sent",   "provider_message_id": "SM…", "error_code": null},
      {"message_id": "…", "to": "+525512345672", "status": "failed", "provider_message_id": "",    "error_code": "twilio_21211"}
    ],
    "request_id": "…"
  }
  ```

  Códigos: `202` si todo quedó encolado, `200` si todo salió, `207` si unos sí
  y otros no, `502` si ninguno. Antes, un fallo parcial devolvía `400 Invalid
  data` y el cliente no podía saber cuáles se habían enviado ya, así que
  reintentar duplicaba mensajes.
* **Se limitan destinatarios y longitud** (`MESSAGING_MAX_RECIPIENTS`,
  `MESSAGING_MAX_BODY_CHARS`). Antes no había tope: se podía mandar un cuerpo
  de un megabyte a mil números en una sola petición, y Twilio factura cada
  segmento de 153 caracteres.
* **Los duplicados se eliminan.** Mandar dos veces al mismo número en la misma
  petición se cobraba dos veces.

Un cambio de comportamiento a tener en cuenta: `system` y `notification` del
endpoint de plantilla ahora se **sanean**. Se conservan las etiquetas de
formato que ya se usaban (`<b>`, `<em>`, `<br>`, listas), pero se eliminan
enlaces, imágenes, formularios, `style` y `script`. Antes se insertaban con el
filtro `|safe`, que no filtra nada: cualquiera con una credencial podía meter
un enlace en un correo firmado por el dominio de la empresa.
