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).
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'&¬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.