Fase 1a: microservicio tcef-notifications (workers FCM v1 + email)

Sustituye el envío de push por la API legacy de GCM (muerta desde jun-2024) y
saca el envío del observer de Meteor a una cola BullMQ controlada.

- poller: consume 'notifications' pendientes (campos correctos; el cron viejo
  tenía un typo nofitied/emailNofitied que nunca casaba)
- fcm-worker: firebase-admin (FCM HTTP v1), payload compatible con la app
  Flutter (FLUTTER_NOTIFICATION_CLICK, collapseKey=_id, data); purga tokens
  muertos sin reintentar
- email-worker: nodemailer + plantillas new-fire portadas verbatim
- idempotencia persistente: notification_sends, indice unico (notificationId,
  channel) -> sobrevive reinicios y replays
- salvaguardas anti-spam: modos dry-run/shadow/canary/full, exclusion mutua por
  canal (OWNED_CHANNELS + notifDisabledChannels en Meteor), kill switch,
  limites por usuario/dia y circuit breaker por batch
- docs/legacy-behavior.md (caracterizacion) + docs/rollout.md
- 47 tests (vitest + mongodb-memory-server), typecheck y build OK
- deploy: systemd + pm2 (Node 22 standalone)

Falta el service account de Firebase para envio real de push (pedir al usuario).
This commit is contained in:
vjrj 2026-07-13 00:10:29 +02:00
commit d20e168c90
39 changed files with 8378 additions and 0 deletions

161
docs/legacy-behavior.md Normal file
View file

@ -0,0 +1,161 @@
# Comportamiento del sistema viejo (caracterización)
> Fuente: `todos-contra-el-fuego-web` (Meteor 1.6). Ficheros leídos:
> `imports/modules/server/notificationsProcess.js`,
> `imports/startup/server/notificationsObserver.js`,
> `imports/startup/server/cron.js`,
> `imports/api/Notifications/Notifications.js`,
> `imports/modules/server/send-email.js`,
> `private/email-templates/new-fire-{es,en}.{html,txt}`,
> `public/locales/{es,en,gl}/common.json`.
>
> Este documento fija el comportamiento que el nuevo servicio debe reproducir
> **campo a campo**. Los tests de caracterización (`test/legacy-behavior.test.ts`)
> lo verifican.
## 1. Esquema del doc `notifications`
Colección `notifications` (db `fuegos`, `idGeneration: 'MONGO'``_id` es
`ObjectId`, no string).
| Campo | Tipo | Notas |
|---|---|---|
| `_id` | ObjectId | generado por Mongo; usado como `tag`/`collapse_key` de FCM |
| `userId` | String | id del user de Meteor (string, no ObjectId) |
| `subsId` | ObjectId | id de la suscripción (opcional, blackbox) |
| `content` | String | texto de la alerta (viene de node-red, con prefijo `🔥 ` y sufijo `:`) |
| `geo` | `{ type: 'Point', coordinates: [lng, lat] }` | GeoJSON — **coordinates[0]=lng, [1]=lat** |
| `type` | String | **`'mobile'`** → push FCM · **`'web'`** → email. Es el canal. |
| `notified` | Boolean? | `true` cuando la push se envió con éxito (canal mobile) |
| `notifiedAt` | Date? | timestamp del envío push |
| `emailNotified` | Boolean? | `true` cuando el email se envió (canal web) |
| `emailNotifiedAt` | Date? | timestamp del envío email |
| `when` | Date | momento de detección del fuego |
| `sealed` | String | id/slug del fuego; usado en la URL `fire/<sealed>` |
| `createdAt` / `updatedAt` | Date | timestamps de simpl-schema |
**El canal está determinado por `type`**: un doc es o mobile o web, nunca ambos.
## 2. Condiciones de envío
Un doc se procesa (`processNotif`) si `isMailServerMaster` (solo el proceso
master pm2 envía) **y**:
- **mobile**: `type === 'mobile'` && `notified !== true` && hay un
`fcmApiToken` válido en settings.
- **web**: `type === 'web'` && `emailNotified !== true`.
Disparadores:
1. **Observer** (`notificationsObserver.js`): `Notifications.find().observe()`
llama `processNotif` en `added` y en `changed`, inmediatamente, sin cola ni
batching. Es la vía principal (y la que satura).
2. **Cron** (`cron.js`, SyncedCron cada 15 min): reprocesa pendientes.
**⚠️ BUG en el sistema viejo**: la query usa campos **mal escritos**
(`nofitied` y `emailNofitied` en vez de `notified`/`emailNotified`), por lo
que **el cron nunca encuentra nada** — el reproceso de pendientes está roto
desde siempre. El nuevo poller usa los nombres correctos.
## 3. Push FCM (canal `mobile`)
Librería vieja: `node-gcm` contra la **API legacy GCM/FCM** (apagada por Google
en jun-2024 → todas las push devuelven `FCM error: 404` desde entonces; ver
`plan-modernizacion/ESTADO.md`). Token del user en `user.fireBaseToken`.
Mensaje construido (`gcm.Message`):
- **notification**:
- `title`: `i18n.t('Alerta de fuego')` → es `"Alerta de fuego"`, en `"Alert of fire"`.
- `body`: `trim(notif.content)` (ver §5).
- `click_action`: `'FLUTTER_NOTIFICATION_CLICK'` (lo espera la app Flutter).
- `tag`: `notif._id` (dedupe/colapso de la notificación en el dispositivo).
- `sound`: `'default'`.
- `icon`: `'launch_image'`.
- **data** (todo string en FCM v1):
- `id`: `notif._id._str` (hex de 24 chars del ObjectId).
- `description`: body (= `trim(content)`).
- `lat`: `notif.geo.coordinates[1]`.
- `lon`: `notif.geo.coordinates[0]`.
- `when`: `notif.when`.
- `subsId`: `notif.subsId._str`.
- `sealed`: `notif.sealed`.
Al recibir respuesta:
- éxito → `Notifications.update(_id, { $set: { notified: true, notifiedAt: new Date() } })`.
- error → `console.error('FCM error: ...')` + log a Sentry (raven). **No** marca
`notified` (se reintenta en el siguiente ciclo — que con el bug del cron no
llega, así que en la práctica solo lo reintenta el observer si el doc cambia).
- si el user **no** tiene `fireBaseToken` → warn, no envía, no marca.
### Equivalencia FCM v1 (nuevo servicio)
FCM HTTP v1 (`firebase-admin`) no tiene `notification.tag`/`icon`/`click_action`
en la raíz; el mapeo Android equivalente:
- `message.android.collapseKey` = `notif._id` (equivale a `tag`).
- `message.android.notification.clickAction` = `'FLUTTER_NOTIFICATION_CLICK'`.
- `message.android.notification.sound` = `'default'`.
- `message.android.notification.icon` = `'launch_image'`.
- `message.notification.{title,body}` = title/body de arriba.
- `message.data` = los mismos campos (todos como string).
- `message.token` = `user.fireBaseToken`.
Tokens inválidos (`messaging/registration-token-not-registered`,
`messaging/invalid-argument` sobre el token) → marcar el token como muerto en el
user (`fireBaseToken` a null / campo `fireBaseTokenDeadAt`), **no reintentar**.
## 4. Email (canal `web`)
`processNotif` (rama web) → `getEmailOf(user)` para `emailAddress` + `firstName`
(solo si el email está **verificado**; si no hay email válido, **borra el doc**
`notifications` para no reintentar).
Construcción:
- `img` = URL de Google Static Maps (`google-maps-image-api-url`): center
`lat,lng`, `size 640x480`, `zoom 16`, `maptype hybrid`, `language es`,
marker con `fireIconUrl`. Key = `Meteor.settings.gmaps.key`.
- `fireUrl` = `${ROOT_URL}fire/${notif.sealed}`.
- `message` = `` `${trim(notif.content)} (${i18n.t('fireDetectedAt', { when: dateLongFormat(notif.when) })}).` ``
- `fireDetectedAt`: es `"fuego detectado el {{when}}"`, en `"fire detected on {{when}}"`, gl `"lume detectado o {{when}}"`.
- `dateLongFormat(when)` = `moment.tz(when, tzGuess).format('LLLL (z)')` (fecha
larga localizada + zona). En el nuevo servicio se replica con Luxon:
`DateTime.fromJSDate(when).setZone(tz).setLocale(lang).toFormat("cccc, d 'de' LLLL 'de' yyyy HH:mm '('ZZZZ')'")` (aprox; el objetivo es una fecha larga legible con zona — no es un campo comparado byte a byte).
- `subject` = `subjectTruncate(message, 70)` (trunca a 70 chars por frontera de
palabra + `...`).
- Plantilla `new-fire` (`-es`/`-en`), Handlebars, con CSS inline vía `juice`.
Vars: `applicationName` (=`i18n.t('AppName')`), `firstName`, `message`,
`fireUrl`, `img`, `subsUrl` (=`${ROOT_URL}subscriptions`).
- Envío con nodemailer (`MAIL_URL` de settings). Plantillas txt + html.
Tras enviar → `Notifications.update(_id, { $set: { emailNotified: true, emailNotifiedAt: new Date() } })`.
Las plantillas `new-fire-{es,en}.{html,txt}` se **portan tal cual** a
`src/templates/` (idénticas a las de `private/email-templates/` de la web).
## 5. `trim(content)` — normalización del texto
```js
message.replace(/^🔥 /, '').replace(/:$/, '')
```
Quita el prefijo `🔥 ` (emoji + espacio) y el `:` final que node-red añade para
Telegram pero que no queremos en push/email.
## 6. Dedupe existente (aguas arriba, en node-red)
node-red, al **crear** los docs, usa `cacheKey` = `lat,lon` con radio 500m para
no crear notificaciones duplicadas del mismo fuego. Eso es de la **fase 1b**
(matching); el servicio de la 1a **consume** los docs ya creados. La 1a añade su
propia idempotencia **de envío** (`notification_sends`, único por
`(notificationId, channel)`) para no reenviar el mismo doc dos veces.
## 7. Cohabitación durante la migración
- El observer viejo procesa `notifications` en tiempo real. El nuevo poller
también. **Exclusión mutua por canal** (obligatoria): un flag en Meteor
settings `private.notifDisabledChannels: ['mobile','web']` hace que el
observer/cron viejos **ignoren** esos `type`. El servicio nuevo solo debe
tener activado (`OWNED_CHANNELS`) un canal **después** de deshabilitarlo en
Meteor. Ver `docs/rollout.md`.
- Ambos escriben `notified`/`emailNotified` con la misma semántica, de modo que
el modo `shadow` puede comparar decisiones.

119
docs/rollout.md Normal file
View file

@ -0,0 +1,119 @@
# Plan de puesta en marcha (rollout) — fase 1a
> **Regla de oro**: nunca se salta un modo, y ningún canal pasa a `full` sin
> confirmación explícita del usuario. El riesgo nº1 es spamear a los usuarios.
## Modos (env `MODE`)
| Modo | Envía | Para qué |
|---|---|---|
| `dry-run` | **no** | calcula el payload, resuelve destinatario, loguea. Detecta errores de datos/plantillas sin tocar a nadie. No reserva en `notification_sends`. |
| `shadow` | **no** | igual que dry-run pero pensado para correr ≥1 día en paralelo con el sistema viejo y comparar decisiones (logs estructurados). |
| `canary` | **solo cohorte** | envía únicamente a `CANARY_USER_IDS` / `CANARY_SUBS_IDS` (dispositivos/suscripciones propias). |
| `full` | **todos** | operación normal. |
Salvaguardas activas en todos los modos: `KILL_SWITCH=1` (parada total), límite
`LIMIT_PER_USER_DAY`, circuit breaker `LIMIT_MAX_PER_BATCH`, idempotencia
persistente `notification_sends` (único por `notificationId+channel`).
## Exclusión mutua por canal (obligatoria)
El sistema viejo (Meteor) y el nuevo **jamás** deben enviar por el mismo canal a
la vez. Se controla con:
- **Meteor** (`todos-contra-el-fuego-web`): `settings.private.notifDisabledChannels`,
un array con `"mobile"` y/o `"web"`. Si un canal está ahí, el observer/cron
viejos lo ignoran (guard en `notificationsProcess.js`). Cambio reversible sin
desplegar código: editar settings y reiniciar el proceso Meteor.
- **Servicio nuevo** (`OWNED_CHANNELS`): `fcm` y/o `email`. Solo procesa los que
posee.
Mapa de canales: `mobile ↔ fcm`, `web ↔ email`.
> **Orden seguro de cutover de un canal**: (1) añadir el canal a
> `notifDisabledChannels` en Meteor y reiniciar → el viejo deja de enviarlo;
> (2) confirmar en logs que el viejo ya no lo toca; (3) añadir el canal a
> `OWNED_CHANNELS` del servicio y subir el modo. **Nunca** al revés.
## Secuencia (por canal, empezando por FCM)
FCM es el primer cutover y el más seguro: **el canal FCM del viejo está muerto
desde jun-2024** (API legacy GCM apagada → 404), así que no hay envío real que
solapar. Ver `plan-modernizacion/ESTADO.md`.
### Paso 0 — Infra en shiva (una vez)
1. Node 22 standalone (nvm o binario), **no** el Node 8 del sistema.
2. Redis local (`apt install redis-server` o binario/contenedor).
3. Clonar el repo, `npm ci && npm run build`.
4. Config desde el repo privado `tcef-private-config`:
- `MONGO_URL` (rsmain, db `fuegos`), `MAIL_URL`, `GMAPS_KEY`, `FIRE_ICON_URL`,
`ROOT_URL`.
- **Service account de Firebase** (`FCM_SERVICE_ACCOUNT=/ruta/al.json`) —
descargar de la consola Firebase del proyecto `org.comunes.fires`
(`angular-cosmos-108908`) → Configuración → Cuentas de servicio → Generar
clave privada. Guardar **solo** en el repo privado / disco de shiva.
### Paso 1 — dry-run FCM (unos ciclos)
```
MODE=dry-run OWNED_CHANNELS=fcm KILL_SWITCH=0
```
- Corre varios imports NASA (cada 15 min). Verifica en logs: payloads bien
formados, cuántos users sin token, cuántos con token. **No** se envía nada.
- Criterio de avance: sin errores de datos/plantilla; volumen esperado.
### Paso 2 — shadow FCM (≥1 día)
```
MODE=shadow OWNED_CHANNELS=fcm
```
- Compara decisiones con el sistema viejo (que en FCM no envía nada real igual).
- Criterio de avance: sin discrepancias inesperadas; sin duplicados en
`notification_sends`.
### Paso 3 — cutover + canary FCM (dispositivos propios)
1. En Meteor: `notifDisabledChannels: ["mobile"]`, reiniciar `tcef_web*`.
2. Verificar que el viejo ya no procesa mobile.
3. Servicio:
```
MODE=canary OWNED_CHANNELS=fcm CANARY_USER_IDS=<tu userId> FCM_SERVICE_ACCOUNT=...
```
4. Instala la app Flutter en un dispositivo propio con sesión iniciada, genera
un fuego sintético cerca de tu suscripción → debe llegar la push (formato e
idioma correctos). Verifica `notification_sends` (status `sent`) y logs de
firebase-admin.
5. Simulacro de idempotencia: mata el worker a mitad de batch y reinícialo →
cero reenvíos (comprobar `notification_sends`).
- Criterio de avance: push llega bien a la cohorte varios días, sin duplicados.
### Paso 4 — full FCM (**requiere confirmación explícita del usuario**)
```
MODE=full OWNED_CHANNELS=fcm
```
- Los `fireBaseToken` llevan ~2 años sin uso → alta tasa de token muerto en el
primer envío. Es esperado: se purgan (`fireBaseToken=null`), no se reintenta.
- Vigilar el circuit breaker y el límite por usuario/día.
### Paso 5 — email (repetir la secuencia, solo tras FCM estable)
- dry-run → shadow (**aquí el viejo SÍ envía email**, así que el shadow es
crítico: comparar a quién marcó el viejo como `emailNotified` vs. a quién
enviaríamos, volcar discrepancias) → cutover (`notifDisabledChannels:
["mobile","web"]` en Meteor) → canary (buzón de test vía `MAIL_REDIRECT_TO`) →
full (con confirmación).
## Rollback (por canal)
1. Servicio: `KILL_SWITCH=1` (parada inmediata) o quitar el canal de
`OWNED_CHANNELS`.
2. Meteor: quitar el canal de `notifDisabledChannels`, reiniciar → el viejo
reasume ese canal.
3. Nada se pierde: los docs pendientes siguen en `notifications` con
`notified`/`emailNotified` sin marcar.
## Checklist de verificación (por paso)
- [ ] Tests en verde (`npm test`).
- [ ] dry-run: payloads OK, sin errores, volumen esperado.
- [ ] shadow ≥1 ciclo NASA real sin discrepancias ni duplicados.
- [ ] Exclusión por canal confirmada (viejo no toca el canal migrado).
- [ ] Canary: entrega correcta a cohorte propia, formato/idioma OK.
- [ ] Simulacro kill-a-mitad-de-batch → cero reenvíos.
- [ ] Confirmación explícita del usuario antes de `full`.