tcef-notifications/docs/legacy-behavior.md
vjrj d20e168c90 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).
2026-07-13 00:10:29 +02:00

7.8 KiB

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

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.