# 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/` | | `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 ```js 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.