tcef-notifications/docs/legacy-behavior.md
vjrj a5ba2cdcf1 Fase 1a: driver mongodb v3.7 (produccion es MongoDB 3.2)
El smoke-test contra prod revelo MongoDB 3.2.11 (EOL): el driver v6 exige >=4.2
y no conecta. Cambios:

- mongodb ^3.7.4 + @types/mongodb; db.ts con useUnifiedTopology, sin retryWrites
- sends.ts: deteccion de duplicate-key (11000) sin depender de MongoServerError
- poller.ts: FilterQuery en vez de Filter
- tests de integracion migrados de mongodb-memory-server a un fake en memoria
  (test/fake-repo.ts) porque no hay mongod 3.x ejecutable en hosts modernos;
  emula operadores ($ne/$in/$gte/$exists), $set/$unset e indice unico (11000)
- docs: legacy-behavior.md §0 (entorno MongoDB 3.2, sin change streams -> fase
  1b usara polling; recomendacion de upgrade de Mongo como fase propia)

47 tests en verde, typecheck y build OK. Datos reales del import: 413 pendientes
FCM, 0 email, 8176 users con fireBaseToken.
2026-07-13 00:58:27 +02:00

8.9 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.

0. Entorno de datos (crítico)

  • Producción corre MongoDB 3.2.11 (verificado en shiva vía buildInfo, jul-2026). Es EOL desde 2018. Implica:
    • El servicio usa el driver mongodb v3.7 (el v6 exige servidor ≥ 4.2 y no conecta a 3.2). No subir el driver sin subir antes el servidor.
    • No hay change streams (requieren 3.6+) → la fase 1b debe usar el polling de respaldo, no change streams.
    • Los tests de integración corren contra un fake en memoria (test/fake-repo.ts), porque en hosts modernos no se puede descargar/ejecutar un mongod tan antiguo (glibc/libssl).
  • Cifras reales del import (jul-2026): ~413 docs notifications pendientes de FCM, 0 de email, 8176 users con fireBaseToken (muchos llevarán ~2 años muertos → alta tasa de token inválido en el primer envío real).
  • Recomendación de fondo (fuera de fase 1a): planificar un upgrade de MongoDB como fase propia; 3.2 bloquea drivers modernos, change streams y es un riesgo de seguridad. Afecta a Meteor 1.6 y node-red, que dependen de él.

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.