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.
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
mongodbv3.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).
- El servicio usa el driver
- Cifras reales del import (jul-2026): ~413 docs
notificationspendientes de FCM, 0 de email, 8176 users confireBaseToken(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'&¬ified !== true&& hay unfcmApiTokenválido en settings. - web:
type === 'web'&&emailNotified !== true.
Disparadores:
- Observer (
notificationsObserver.js):Notifications.find().observe()llamaprocessNotifenaddedy enchanged, inmediatamente, sin cola ni batching. Es la vía principal (y la que satura). - Cron (
cron.js, SyncedCron cada 15 min): reprocesa pendientes. ⚠️ BUG en el sistema viejo: la query usa campos mal escritos (nofitiedyemailNofitieden vez denotified/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 marcanotified(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 atag).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): centerlat,lng,size 640x480,zoom 16,maptype hybrid,language es, marker confireIconUrl. 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íajuice. Vars:applicationName(=i18n.t('AppName')),firstName,message,fireUrl,img,subsUrl(=${ROOT_URL}subscriptions). - Envío con nodemailer (
MAIL_URLde 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
notificationsen tiempo real. El nuevo poller también. Exclusión mutua por canal (obligatoria): un flag en Meteor settingsprivate.notifDisabledChannels: ['mobile','web']hace que el observer/cron viejos ignoren esostype. El servicio nuevo solo debe tener activado (OWNED_CHANNELS) un canal después de deshabilitarlo en Meteor. Verdocs/rollout.md. - Ambos escriben
notified/emailNotifiedcon la misma semántica, de modo que el modoshadowpuede comparar decisiones.