tcef-notifications/docs/rollout.md
vjrj a1ed206985 Fase 1b: cableado del matcher (ingesta polling, runner shadow/full, comparador)
- config: bloque matcher (MATCHER_MODE off/shadow/full, IRON_PASSWORD, audiencia,
  poll, batch) + validacion
- db: colecciones subscriptions/activefires/notifications_shadow/matcher_state +
  ensureMatcherIndexes (2dsphere en notifications_shadow para el dedupe 500m)
- matcher/context: MatchContext sobre Mongo real (candidatas geo $near 1000km +
  audiencia; dedupe geo $near 500m; lang del owner)
- matcher/ingest: polling de activefires por createdAt>checkpoint (Mongo 3.2 sin
  change streams), checkpoint persistido en matcher_state
- matcher/runner: loop shadow(->notifications_shadow)/full(->notifications),
  idempotente por dedupe+checkpoint
- matcher/compare: comparador de shadow (shadowOnly=spam, realOnly=faltantes,
  contentMismatches) — el gate antes del cutover
- index: arranca el matcher; fake-repo ampliado (upsert, sort, $gt/$lt)
- docs/env/README actualizados

73 tests en verde, typecheck y build OK.
2026-07-13 18:07:06 +02:00

6.9 KiB

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.

Fase 1b — matcher geoespacial (rollout)

El matcher genera los docs notifications (fuego → subs 2dsphere) que hoy crea node-red. Config: MATCHER_MODE (off/shadow/full), IRON_PASSWORD (sella sealed; = settings.private.ironPassword), MATCHER_AUDIENCE (web,mobile). Ingesta por polling de activefires (Mongo 3.2 no tiene change streams). Ver docs/legacy-matching.md.

Secuencia (empezar solo con la fase 1a estable):

  1. MATCHER_MODE=shadow ≥3 días / varios ciclos NASA reales. Escribe en notifications_shadow (no toca notifications, node-red sigue generando).
  2. Comparar con src/matcher/compare.ts (compareShadow): correr el comparador sobre la ventana y exigir shadowOnly vacío (0 notifs de más = 0 spam) — cada realOnly (faltante) se analiza uno a uno; contentMismatches revisados.
  3. Cutover (con confirmación explícita): desactivar el subflow de matching en node-red (exportar el flow antes como respaldo; documentar qué nodos) y poner MATCHER_MODE=full. node-red deja de insertar en notifications; el matcher pasa a ser el único origen. Los workers de 1a consumen igual.
  4. Rollback: reimportar el flow de node-red y MATCHER_MODE=shadow/off. Los workers de 1a siguen consumiendo notifications venga de donde venga.

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.