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).
This commit is contained in:
vjrj 2026-07-13 00:10:29 +02:00
commit d20e168c90
39 changed files with 8378 additions and 0 deletions

50
.env.example Normal file
View file

@ -0,0 +1,50 @@
# tcef-notifications — ejemplo de configuración por entorno.
# Copia a .env (o usa config.json). Los SECRETOS salen del repo privado
# tcef-private-config / de shiva — NUNCA se commitean aquí.
# --- Modo y salvaguardas ---
MODE=dry-run # dry-run | shadow | canary | full
KILL_SWITCH=0 # 1 = parada total de envíos
OWNED_CHANNELS= # fcm,email (vacío = no procesa nada)
CANARY_USER_IDS= # userIds separados por coma (modo canary)
CANARY_SUBS_IDS= # subsIds (ObjectId hex) separados por coma
# --- Límites ---
LIMIT_PER_USER_DAY=20 # máx envíos/usuario/24h por canal (0 = sin límite)
LIMIT_MAX_PER_BATCH=2000 # circuit breaker: para y alerta si un ciclo supera esto
# --- Poller ---
POLL_INTERVAL_MS=10000
POLL_BATCH_SIZE=500
# --- Reintentos ---
RETRY_ATTEMPTS=3
RETRY_BACKOFF_MS=5000
# --- Mongo (rsmain, db fuegos) — SECRETO ---
MONGO_URL=mongodb://USER:PASS@simone:27017,rbg:27017,rosaparks:27017/fuegos?replicaSet=rsmain
MONGO_DB=fuegos
# --- Redis ---
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# REDIS_PASSWORD=
REDIS_DB=0
# --- FCM (HTTP v1) — SECRETO: service account JSON del proyecto org.comunes.fires ---
FCM_SERVICE_ACCOUNT=/opt/tcef-notifications/secrets/firebase-service-account.json
FCM_PROJECT_ID=angular-cosmos-108908
# --- Email — SECRETO: MAIL_URL de los settings de producción de Meteor ---
MAIL_URL=smtps://USER:PASS@smtp.host:465
MAIL_FROM=Todos contra el Fuego <no-reply@comunes.org>
# MAIL_REDIRECT_TO=buzon-de-test@example.com # canary/staging: redirige todo el email aquí
# --- Enlaces / mapas ---
ROOT_URL=https://fuegos.comunes.org/
GMAPS_KEY= # Meteor.settings.gmaps.key
FIRE_ICON_URL= # Meteor.settings.private.fireIconUrl
# --- Logs ---
LOG_LEVEL=info
SERVICE_NAME=tcef-notifications

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
node_modules/
dist/
*.log
logs/
.env
.env.*
!.env.example
config.json
!config.example.json
# Secrets — NEVER commit. Firebase service account, real settings.
firebase-service-account*.json
service-account*.json
*.pem
.DS_Store

69
README.md Normal file
View file

@ -0,0 +1,69 @@
# tcef-notifications
Microservicio de **envío de notificaciones** para *Todos contra el Fuego*
(alertas tempranas de incendios NASA FIRMS). Sustituye el envío de push que
hacía la web Meteor con la API legacy de GCM (apagada por Google en jun-2024) y
saca el envío del observer de Meteor a una cola controlada.
Fase **1a** del [plan de modernización](../plan-modernizacion/fase-1a-notif-workers.md):
solo **workers de envío**. Consume la colección `notifications` que hoy genera
node-red; el *matching* geoespacial (fase 1b) y Telegram (fase 1c) vienen
después.
## Qué hace
- **poller**: cada N s busca docs pendientes en `notifications` (con los nombres
de campo **correctos** — el cron viejo tenía un typo, ver
[`docs/legacy-behavior.md`](docs/legacy-behavior.md) §2) y encola un job por
canal.
- **fcm-worker**: envía push con `firebase-admin` (FCM HTTP v1), preservando el
payload que espera la app Flutter (`FLUTTER_NOTIFICATION_CLICK`, collapseKey =
`_id`, data). Purga tokens muertos sin reintentar.
- **email-worker**: `nodemailer` + las plantillas `new-fire` portadas verbatim.
- **idempotencia persistente**: colección `notification_sends`, índice único
`(notificationId, channel)` — sobrevive reinicios y replays de cola.
## Salvaguardas anti-spam
El riesgo nº1 es spamear a los usuarios. Por eso:
- **Modos graduales** `MODE`: `dry-run``shadow``canary``full`. Nunca se
salta un modo; `full` solo con confirmación explícita del usuario.
- **Exclusión mutua por canal**: `OWNED_CHANNELS` aquí + `notifDisabledChannels`
en Meteor. El viejo y el nuevo jamás envían por el mismo canal a la vez.
- **Kill switch**: `KILL_SWITCH=1` detiene todo envío al instante.
- **Límites**: máx envíos/usuario/día y circuit breaker por batch (para y
alerta).
Detalle del despliegue y la secuencia de cutover: [`docs/rollout.md`](docs/rollout.md).
## Desarrollo
```bash
npm ci
npm test # 47 tests (vitest + mongodb-memory-server, sin infra externa)
npm run typecheck
npm run build # -> dist/ (+ plantillas)
```
## Ejecución
Config por env (ver [`.env.example`](.env.example)) o `config.json` (ver
`config.example.json`); env gana. Secretos (Mongo, `MAIL_URL`, service account
de Firebase) desde el repo privado `tcef-private-config` — nunca en este repo.
```bash
MODE=dry-run OWNED_CHANNELS=fcm MONGO_URL=... npm start
```
Despliegue en shiva: `deploy/tcef-notifications.service` (systemd) o
`deploy/pm2-tcef-notifications.json`. Node 22 standalone (no el Node 8 del
sistema) + Redis local. En fase 3 pasa a Docker Compose.
## Estado
- [x] Workers FCM v1 + email, poller, idempotencia, salvaguardas, tests.
- [x] Flag de cutover en Meteor (`notifDisabledChannels`).
- [ ] **Falta**: service account de Firebase (pedir al usuario) para poder
enviar push de verdad.
- [ ] Rollout en shiva (dry-run → shadow → canary → full), con el usuario.

17
config.example.json Normal file
View file

@ -0,0 +1,17 @@
{
"mode": "dry-run",
"killSwitch": false,
"ownedChannels": [],
"dbName": "fuegos",
"redis": { "host": "127.0.0.1", "port": 6379, "db": 0 },
"poll": { "intervalMs": 10000, "batchSize": 500 },
"limits": { "perUserPerDay": 20, "maxPerBatch": 2000 },
"canary": { "userIds": [], "subsIds": [] },
"retries": { "attempts": 3, "backoffMs": 5000 },
"fcm": { "projectId": "angular-cosmos-108908" },
"email": { "from": "Todos contra el Fuego <no-reply@comunes.org>" },
"rootUrl": "https://fuegos.comunes.org/",
"gmaps": {},
"logLevel": "info",
"serviceName": "tcef-notifications"
}

View file

@ -0,0 +1,22 @@
{
"//": "Alternativa pm2 (mientras shiva siga con pm2). Node 22 standalone, NO el node del sistema. Los secretos van en el .env leído por dotenv o exportados antes de arrancar pm2. En fase 3 esto pasa a Docker Compose.",
"apps": [
{
"name": "tcef-notifications",
"cwd": "/opt/tcef-notifications",
"script": "dist/index.js",
"interpreter": "/opt/node22/bin/node",
"node_args": "--enable-source-maps",
"instances": 1,
"exec_mode": "fork",
"max_restarts": 10,
"restart_delay": 5000,
"env": {
"MODE": "dry-run",
"OWNED_CHANNELS": "",
"KILL_SWITCH": "0",
"CONFIG_FILE": "/opt/tcef-notifications/config.json"
}
}
]
}

View file

@ -0,0 +1,31 @@
# systemd unit para tcef-notifications en shiva (o servidor limpio, fase 3).
# Requiere Node 22 standalone (NO el Node 8 del sistema) y Redis local.
#
# sudo cp deploy/tcef-notifications.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now tcef-notifications
# journalctl -u tcef-notifications -f
#
# El EnvironmentFile lleva los SECRETOS (Mongo, MAIL_URL, ruta al service
# account de Firebase) — permisos 600, fuera de git, desde tcef-private-config.
[Unit]
Description=tcef-notifications (FCM v1 + email sender)
After=network-online.target redis-server.service
Wants=network-online.target
[Service]
Type=simple
User=incinera
WorkingDirectory=/opt/tcef-notifications
EnvironmentFile=/opt/tcef-notifications/secrets/tcef-notifications.env
# Ajusta la ruta al Node 22 standalone instalado en shiva:
ExecStart=/opt/node22/bin/node --enable-source-maps dist/index.js
Restart=on-failure
RestartSec=5
# Kill switch de emergencia sin editar la env: systemctl stop tcef-notifications
KillSignal=SIGTERM
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target

161
docs/legacy-behavior.md Normal file
View file

@ -0,0 +1,161 @@
# 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
```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.

119
docs/rollout.md Normal file
View file

@ -0,0 +1,119 @@
# Plan de puesta en marcha (rollout) — fase 1a
> **Regla de oro**: nunca se salta un modo, y ningún canal pasa a `full` sin
> confirmación explícita del usuario. El riesgo nº1 es spamear a los usuarios.
## Modos (env `MODE`)
| Modo | Envía | Para qué |
|---|---|---|
| `dry-run` | **no** | calcula el payload, resuelve destinatario, loguea. Detecta errores de datos/plantillas sin tocar a nadie. No reserva en `notification_sends`. |
| `shadow` | **no** | igual que dry-run pero pensado para correr ≥1 día en paralelo con el sistema viejo y comparar decisiones (logs estructurados). |
| `canary` | **solo cohorte** | envía únicamente a `CANARY_USER_IDS` / `CANARY_SUBS_IDS` (dispositivos/suscripciones propias). |
| `full` | **todos** | operación normal. |
Salvaguardas activas en todos los modos: `KILL_SWITCH=1` (parada total), límite
`LIMIT_PER_USER_DAY`, circuit breaker `LIMIT_MAX_PER_BATCH`, idempotencia
persistente `notification_sends` (único por `notificationId+channel`).
## Exclusión mutua por canal (obligatoria)
El sistema viejo (Meteor) y el nuevo **jamás** deben enviar por el mismo canal a
la vez. Se controla con:
- **Meteor** (`todos-contra-el-fuego-web`): `settings.private.notifDisabledChannels`,
un array con `"mobile"` y/o `"web"`. Si un canal está ahí, el observer/cron
viejos lo ignoran (guard en `notificationsProcess.js`). Cambio reversible sin
desplegar código: editar settings y reiniciar el proceso Meteor.
- **Servicio nuevo** (`OWNED_CHANNELS`): `fcm` y/o `email`. Solo procesa los que
posee.
Mapa de canales: `mobile ↔ fcm`, `web ↔ email`.
> **Orden seguro de cutover de un canal**: (1) añadir el canal a
> `notifDisabledChannels` en Meteor y reiniciar → el viejo deja de enviarlo;
> (2) confirmar en logs que el viejo ya no lo toca; (3) añadir el canal a
> `OWNED_CHANNELS` del servicio y subir el modo. **Nunca** al revés.
## Secuencia (por canal, empezando por FCM)
FCM es el primer cutover y el más seguro: **el canal FCM del viejo está muerto
desde jun-2024** (API legacy GCM apagada → 404), así que no hay envío real que
solapar. Ver `plan-modernizacion/ESTADO.md`.
### Paso 0 — Infra en shiva (una vez)
1. Node 22 standalone (nvm o binario), **no** el Node 8 del sistema.
2. Redis local (`apt install redis-server` o binario/contenedor).
3. Clonar el repo, `npm ci && npm run build`.
4. Config desde el repo privado `tcef-private-config`:
- `MONGO_URL` (rsmain, db `fuegos`), `MAIL_URL`, `GMAPS_KEY`, `FIRE_ICON_URL`,
`ROOT_URL`.
- **Service account de Firebase** (`FCM_SERVICE_ACCOUNT=/ruta/al.json`) —
descargar de la consola Firebase del proyecto `org.comunes.fires`
(`angular-cosmos-108908`) → Configuración → Cuentas de servicio → Generar
clave privada. Guardar **solo** en el repo privado / disco de shiva.
### Paso 1 — dry-run FCM (unos ciclos)
```
MODE=dry-run OWNED_CHANNELS=fcm KILL_SWITCH=0
```
- Corre varios imports NASA (cada 15 min). Verifica en logs: payloads bien
formados, cuántos users sin token, cuántos con token. **No** se envía nada.
- Criterio de avance: sin errores de datos/plantilla; volumen esperado.
### Paso 2 — shadow FCM (≥1 día)
```
MODE=shadow OWNED_CHANNELS=fcm
```
- Compara decisiones con el sistema viejo (que en FCM no envía nada real igual).
- Criterio de avance: sin discrepancias inesperadas; sin duplicados en
`notification_sends`.
### Paso 3 — cutover + canary FCM (dispositivos propios)
1. En Meteor: `notifDisabledChannels: ["mobile"]`, reiniciar `tcef_web*`.
2. Verificar que el viejo ya no procesa mobile.
3. Servicio:
```
MODE=canary OWNED_CHANNELS=fcm CANARY_USER_IDS=<tu userId> FCM_SERVICE_ACCOUNT=...
```
4. Instala la app Flutter en un dispositivo propio con sesión iniciada, genera
un fuego sintético cerca de tu suscripción → debe llegar la push (formato e
idioma correctos). Verifica `notification_sends` (status `sent`) y logs de
firebase-admin.
5. Simulacro de idempotencia: mata el worker a mitad de batch y reinícialo →
cero reenvíos (comprobar `notification_sends`).
- Criterio de avance: push llega bien a la cohorte varios días, sin duplicados.
### Paso 4 — full FCM (**requiere confirmación explícita del usuario**)
```
MODE=full OWNED_CHANNELS=fcm
```
- Los `fireBaseToken` llevan ~2 años sin uso → alta tasa de token muerto en el
primer envío. Es esperado: se purgan (`fireBaseToken=null`), no se reintenta.
- Vigilar el circuit breaker y el límite por usuario/día.
### Paso 5 — email (repetir la secuencia, solo tras FCM estable)
- dry-run → shadow (**aquí el viejo SÍ envía email**, así que el shadow es
crítico: comparar a quién marcó el viejo como `emailNotified` vs. a quién
enviaríamos, volcar discrepancias) → cutover (`notifDisabledChannels:
["mobile","web"]` en Meteor) → canary (buzón de test vía `MAIL_REDIRECT_TO`) →
full (con confirmación).
## Rollback (por canal)
1. Servicio: `KILL_SWITCH=1` (parada inmediata) o quitar el canal de
`OWNED_CHANNELS`.
2. Meteor: quitar el canal de `notifDisabledChannels`, reiniciar → el viejo
reasume ese canal.
3. Nada se pierde: los docs pendientes siguen en `notifications` con
`notified`/`emailNotified` sin marcar.
## Checklist de verificación (por paso)
- [ ] Tests en verde (`npm test`).
- [ ] dry-run: payloads OK, sin errores, volumen esperado.
- [ ] shadow ≥1 ciclo NASA real sin discrepancias ni duplicados.
- [ ] Exclusión por canal confirmada (viejo no toca el canal migrado).
- [ ] Canary: entrega correcta a cohorte propia, formato/idioma OK.
- [ ] Simulacro kill-a-mitad-de-batch → cero reenvíos.
- [ ] Confirmación explícita del usuario antes de `full`.

5317
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

38
package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "tcef-notifications",
"version": "0.1.0",
"private": true,
"description": "Notification sending microservice for Todos contra el Fuego (FCM v1 + email, later Telegram). Replaces the dead node-gcm push path and the Meteor observer.",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"build": "tsc -p tsconfig.json && node scripts/copy-templates.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"start": "node --enable-source-maps dist/index.js",
"dev": "node --experimental-strip-types --watch src/index.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"bullmq": "^5.34.0",
"firebase-admin": "^13.0.2",
"handlebars": "^4.7.8",
"ioredis": "^5.4.2",
"juice": "^11.0.0",
"luxon": "^3.5.0",
"mongodb": "^6.12.0",
"nodemailer": "^6.9.16",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0"
},
"devDependencies": {
"@types/luxon": "^3.4.2",
"@types/node": "^22.10.2",
"@types/nodemailer": "^6.4.17",
"mongodb-memory-server": "^11.2.0",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}

View file

@ -0,0 +1,6 @@
// Copies Handlebars email templates into dist/ after tsc (tsc ignores non-.ts).
import { cpSync, mkdirSync } from 'node:fs';
mkdirSync('dist/templates', { recursive: true });
cpSync('src/templates', 'dist/templates', { recursive: true });
console.log('templates copied to dist/templates');

159
src/config.ts Normal file
View file

@ -0,0 +1,159 @@
import { readFileSync } from 'node:fs';
import type { Channel, OperatingMode } from './types.js';
/**
* Configuration for tcef-notifications.
*
* Precedence: environment variables override config.json, which overrides
* built-in defaults. Secrets (Mongo URL, MAIL_URL, Firebase service account
* path) come from the private repo `tcef-private-config` / env never committed.
*/
export interface Config {
mode: OperatingMode;
/** Global emergency stop: when true, NOTHING is sent regardless of mode. */
killSwitch: boolean;
/** Channels this service owns. Only these are polled/sent. Must be disabled in Meteor first. */
ownedChannels: Channel[];
mongoUrl: string;
dbName: string;
redis: { host: string; port: number; password?: string; db: number };
poll: {
intervalMs: number;
batchSize: number;
};
limits: {
/** Max sends per user per rolling 24h (per channel). 0 = unlimited. */
perUserPerDay: number;
/** Circuit breaker: if a single poll batch would enqueue more than this, stop and alert. */
maxPerBatch: number;
};
/** canary mode: only these userIds / subsIds actually get sent to. */
canary: { userIds: string[]; subsIds: string[] };
retries: { attempts: number; backoffMs: number };
fcm: {
/** Path to the Firebase service account JSON. Required for real FCM sends. */
serviceAccountPath?: string;
projectId?: string;
};
email: {
mailUrl?: string;
from: string;
/** In canary/staging, redirect all mail here instead of the real recipient. */
redirectTo?: string;
};
/** Base URL for building fire/subscription links (Meteor ROOT_URL). */
rootUrl: string;
gmaps: { key?: string; fireIconUrl?: string };
logLevel: string;
serviceName: string;
}
const DEFAULTS = {
mode: 'dry-run' as OperatingMode,
killSwitch: false,
ownedChannels: [] as Channel[],
dbName: 'fuegos',
poll: { intervalMs: 10_000, batchSize: 500 },
limits: { perUserPerDay: 20, maxPerBatch: 2000 },
canary: { userIds: [] as string[], subsIds: [] as string[] },
retries: { attempts: 3, backoffMs: 5_000 },
rootUrl: 'https://fuegos.comunes.org/',
logLevel: 'info',
serviceName: 'tcef-notifications',
};
function parseBool(v: string | undefined): boolean | undefined {
if (v === undefined) return undefined;
return v === '1' || v.toLowerCase() === 'true';
}
function parseList(v: string | undefined): string[] | undefined {
if (v === undefined) return undefined;
return v.split(',').map((s) => s.trim()).filter(Boolean);
}
function parseChannels(v: string | undefined): Channel[] | undefined {
const list = parseList(v);
if (!list) return undefined;
for (const c of list) {
if (c !== 'fcm' && c !== 'email') throw new Error(`Invalid channel in OWNED_CHANNELS: ${c}`);
}
return list as Channel[];
}
/** Load config from optional config.json + environment. Env wins. */
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
let file: Partial<Config> = {};
const path = env.CONFIG_FILE ?? 'config.json';
try {
file = JSON.parse(readFileSync(path, 'utf8')) as Partial<Config>;
} catch {
// config.json is optional
}
const mode = (env.MODE as OperatingMode) ?? file.mode ?? DEFAULTS.mode;
if (!['dry-run', 'shadow', 'canary', 'full'].includes(mode)) {
throw new Error(`Invalid MODE: ${mode}`);
}
const mongoUrl = env.MONGO_URL ?? file.mongoUrl;
if (!mongoUrl) throw new Error('MONGO_URL is required');
const cfg: Config = {
mode,
killSwitch: parseBool(env.KILL_SWITCH) ?? file.killSwitch ?? DEFAULTS.killSwitch,
ownedChannels: parseChannels(env.OWNED_CHANNELS) ?? file.ownedChannels ?? DEFAULTS.ownedChannels,
mongoUrl,
dbName: env.MONGO_DB ?? file.dbName ?? DEFAULTS.dbName,
redis: {
host: env.REDIS_HOST ?? file.redis?.host ?? '127.0.0.1',
port: Number(env.REDIS_PORT ?? file.redis?.port ?? 6379),
password: env.REDIS_PASSWORD ?? file.redis?.password,
db: Number(env.REDIS_DB ?? file.redis?.db ?? 0),
},
poll: {
intervalMs: Number(env.POLL_INTERVAL_MS ?? file.poll?.intervalMs ?? DEFAULTS.poll.intervalMs),
batchSize: Number(env.POLL_BATCH_SIZE ?? file.poll?.batchSize ?? DEFAULTS.poll.batchSize),
},
limits: {
perUserPerDay: Number(env.LIMIT_PER_USER_DAY ?? file.limits?.perUserPerDay ?? DEFAULTS.limits.perUserPerDay),
maxPerBatch: Number(env.LIMIT_MAX_PER_BATCH ?? file.limits?.maxPerBatch ?? DEFAULTS.limits.maxPerBatch),
},
canary: {
userIds: parseList(env.CANARY_USER_IDS) ?? file.canary?.userIds ?? DEFAULTS.canary.userIds,
subsIds: parseList(env.CANARY_SUBS_IDS) ?? file.canary?.subsIds ?? DEFAULTS.canary.subsIds,
},
retries: {
attempts: Number(env.RETRY_ATTEMPTS ?? file.retries?.attempts ?? DEFAULTS.retries.attempts),
backoffMs: Number(env.RETRY_BACKOFF_MS ?? file.retries?.backoffMs ?? DEFAULTS.retries.backoffMs),
},
fcm: {
serviceAccountPath: env.FCM_SERVICE_ACCOUNT ?? file.fcm?.serviceAccountPath,
projectId: env.FCM_PROJECT_ID ?? file.fcm?.projectId,
},
email: {
mailUrl: env.MAIL_URL ?? file.email?.mailUrl,
from: env.MAIL_FROM ?? file.email?.from ?? 'Todos contra el Fuego <no-reply@comunes.org>',
redirectTo: env.MAIL_REDIRECT_TO ?? file.email?.redirectTo,
},
rootUrl: env.ROOT_URL ?? file.rootUrl ?? DEFAULTS.rootUrl,
gmaps: {
key: env.GMAPS_KEY ?? file.gmaps?.key,
fireIconUrl: env.FIRE_ICON_URL ?? file.gmaps?.fireIconUrl,
},
logLevel: env.LOG_LEVEL ?? file.logLevel ?? DEFAULTS.logLevel,
serviceName: env.SERVICE_NAME ?? file.serviceName ?? DEFAULTS.serviceName,
};
return cfg;
}

47
src/db.ts Normal file
View file

@ -0,0 +1,47 @@
import { MongoClient, type Collection, type Db } from 'mongodb';
import type { Config } from './config.js';
import type { NotificationDoc, SendRecord, UserDoc } from './types.js';
export interface Repos {
client: MongoClient;
db: Db;
notifications: Collection<NotificationDoc>;
users: Collection<UserDoc>;
sends: Collection<SendRecord>;
close(): Promise<void>;
}
export async function connect(cfg: Config): Promise<Repos> {
const client = new MongoClient(cfg.mongoUrl, {
// The service only writes provenance/idempotency; prefer safety.
retryWrites: true,
});
await client.connect();
const db = client.db(cfg.dbName);
const notifications = db.collection<NotificationDoc>('notifications');
const users = db.collection<UserDoc>('users');
const sends = db.collection<SendRecord>('notification_sends');
return {
client,
db,
notifications,
users,
sends,
close: () => client.close(),
};
}
/**
* Ensure the idempotency index. Unique on (notificationId, channel): a doc can
* be sent at most once per channel, surviving restarts and queue replays.
*/
export async function ensureIndexes(repos: Repos): Promise<void> {
await repos.sends.createIndex(
{ notificationId: 1, channel: 1 },
{ unique: true, name: 'uniq_notif_channel' },
);
// For per-user/day rate limiting queries.
await repos.sends.createIndex({ createdAt: 1 }, { name: 'created_at' });
}

59
src/fcm.ts Normal file
View file

@ -0,0 +1,59 @@
import { readFileSync } from 'node:fs';
import admin from 'firebase-admin';
import type { Config } from './config.js';
import type { PushPayload } from './messages.js';
export type FcmSendResult =
| { ok: true; messageId: string }
| { ok: false; deadToken: boolean; error: string };
/** Thin wrapper around firebase-admin so it can be mocked in tests. */
export interface FcmClient {
send(token: string, payload: PushPayload): Promise<FcmSendResult>;
}
const DEAD_TOKEN_CODES = new Set([
'messaging/registration-token-not-registered',
'messaging/invalid-registration-token',
'messaging/invalid-argument',
]);
export function createFcmClient(cfg: Config): FcmClient {
if (!cfg.fcm.serviceAccountPath) {
throw new Error('FCM_SERVICE_ACCOUNT not configured — cannot send push. Provide the Firebase service account JSON.');
}
const serviceAccount = JSON.parse(readFileSync(cfg.fcm.serviceAccountPath, 'utf8'));
const app = admin.initializeApp(
{ credential: admin.credential.cert(serviceAccount) },
'tcef-fcm',
);
const messaging = app.messaging();
return {
async send(token, payload) {
// Map the old gcm.Message fields to FCM HTTP v1 (see docs/legacy-behavior.md §3).
const message: admin.messaging.Message = {
token,
notification: { title: payload.title, body: payload.body },
data: payload.data,
android: {
collapseKey: payload.collapseKey,
notification: {
clickAction: payload.clickAction,
sound: payload.sound,
icon: payload.icon,
tag: payload.collapseKey,
},
},
};
try {
const messageId = await messaging.send(message);
return { ok: true, messageId };
} catch (err: unknown) {
const code = (err as { code?: string }).code ?? '';
const errorMessage = (err as { message?: string }).message ?? String(err);
return { ok: false, deadToken: DEAD_TOKEN_CODES.has(code), error: `${code} ${errorMessage}`.trim() };
}
},
};
}

176
src/handlers.ts Normal file
View file

@ -0,0 +1,176 @@
import { ObjectId } from 'mongodb';
import type { Config } from './config.js';
import type { Repos } from './db.js';
import type { FcmClient } from './fcm.js';
import type { Mailer } from './mailer.js';
import type { Logger } from './logger.js';
import { buildEmail, buildPush, normalizeLang } from './messages.js';
import { getEmailOf } from './recipient.js';
import { decideSend } from './mode.js';
import { underPerUserLimit } from './limits.js';
import { markResult, release, reserve } from './sends.js';
import type { NotificationDoc, UserDoc } from './types.js';
export interface Deps {
cfg: Config;
repos: Repos;
log: Logger;
/** Present only when the service may actually send (canary/full). */
fcm?: FcmClient;
mailer?: Mailer;
}
/** Transient failure that should be retried by BullMQ (backoff). */
export class RetryableError extends Error {}
async function loadPair(
repos: Repos,
notificationId: string,
): Promise<{ notif: NotificationDoc; user: UserDoc | null } | null> {
const notif = await repos.notifications.findOne({ _id: new ObjectId(notificationId) });
if (!notif) return null;
const user = await repos.users.findOne({ _id: notif.userId });
return { notif, user };
}
/**
* FCM push handler. Mirrors the old processNotif mobile branch, with all
* safeguards. Throws RetryableError on transient failures so BullMQ retries.
*/
export async function processFcmJob(deps: Deps, notificationId: string): Promise<void> {
const { cfg, repos, log } = deps;
const pair = await loadPair(repos, notificationId);
if (!pair) return;
const { notif, user } = pair;
if (notif.type !== 'mobile' || notif.notified === true) return; // guard: already handled
const lang = normalizeLang(user?.lang);
const payload = buildPush(notif, lang); // built even in shadow to catch payload errors
if (!user?.fireBaseToken) {
log.warn({ notificationId, userId: notif.userId }, 'fcm: user has no fireBaseToken');
return; // matches old behavior: warn, no send, no mark
}
const decision = decideSend(cfg, notif);
if (!decision.send) {
log.info(
{ notificationId, userId: notif.userId, mode: cfg.mode, reason: decision.reason, title: payload.title, body: payload.body },
`fcm: not sending (${decision.reason})`,
);
return;
}
// Real-send path (canary/full).
if (!(await underPerUserLimit(repos, notif.userId, 'fcm', cfg.limits.perUserPerDay))) {
log.warn({ notificationId, userId: notif.userId }, 'fcm: per-user/day limit reached, skipping');
return; // no reservation → may be retried after the window
}
if (!deps.fcm) throw new Error('fcm client not configured but mode requires sending');
const won = await reserve(repos, notif._id, 'fcm', cfg.mode);
if (!won) {
log.debug({ notificationId }, 'fcm: already reserved/sent (idempotent skip)');
return;
}
const result = await deps.fcm.send(user.fireBaseToken, payload);
if (result.ok) {
await markResult(repos, notif._id, 'fcm', 'sent', result.messageId);
await repos.notifications.updateOne(
{ _id: notif._id },
{ $set: { notified: true, notifiedAt: new Date() } },
);
log.info({ notificationId, messageId: result.messageId }, 'fcm: sent');
return;
}
if (result.deadToken) {
// Do not retry; purge the dead token (see docs/legacy-behavior.md §3).
await markResult(repos, notif._id, 'fcm', 'dead-token', result.error);
await repos.users.updateOne(
{ _id: notif.userId },
{ $set: { fireBaseToken: null, fireBaseTokenDeadAt: new Date() } },
);
log.warn({ notificationId, userId: notif.userId, error: result.error }, 'fcm: dead token purged');
return;
}
// Transient — release the reservation and let BullMQ retry with backoff.
await release(repos, notif._id, 'fcm');
log.error({ notificationId, error: result.error }, 'fcm: transient send error, will retry');
throw new RetryableError(result.error);
}
/**
* Email handler. Mirrors the old processNotif web branch.
*/
export async function processEmailJob(deps: Deps, notificationId: string): Promise<void> {
const { cfg, repos, log } = deps;
const pair = await loadPair(repos, notificationId);
if (!pair) return;
const { notif, user } = pair;
if (notif.type !== 'web' || notif.emailNotified === true) return;
if (!user) {
log.warn({ notificationId, userId: notif.userId }, 'email: user not found');
return;
}
const { emailAddress, firstName } = getEmailOf(user);
if (!emailAddress) {
// Old behavior: no verified email → remove the doc so it is not retried.
if (cfg.mode === 'full' || cfg.mode === 'canary') {
await repos.notifications.deleteOne({ _id: notif._id });
await markResult(repos, notif._id, 'email', 'no-recipient');
}
log.info({ notificationId, userId: notif.userId }, 'email: no verified recipient');
return;
}
const lang = normalizeLang(user.lang);
const content = buildEmail(notif, lang, firstName, {
rootUrl: cfg.rootUrl,
gmapsKey: cfg.gmaps.key,
fireIconUrl: cfg.gmaps.fireIconUrl,
});
const decision = decideSend(cfg, notif);
if (!decision.send) {
log.info(
{ notificationId, userId: notif.userId, mode: cfg.mode, reason: decision.reason, to: emailAddress, subject: content.subject },
`email: not sending (${decision.reason})`,
);
return;
}
if (!(await underPerUserLimit(repos, notif.userId, 'email', cfg.limits.perUserPerDay))) {
log.warn({ notificationId, userId: notif.userId }, 'email: per-user/day limit reached, skipping');
return;
}
if (!deps.mailer) throw new Error('mailer not configured but mode requires sending');
const won = await reserve(repos, notif._id, 'email', cfg.mode);
if (!won) {
log.debug({ notificationId }, 'email: already reserved/sent (idempotent skip)');
return;
}
try {
await deps.mailer.send(emailAddress, content, 'new-fire');
} catch (err) {
await release(repos, notif._id, 'email');
log.error({ notificationId, err: String(err) }, 'email: transient send error, will retry');
throw new RetryableError(String(err));
}
await markResult(repos, notif._id, 'email', 'sent');
await repos.notifications.updateOne(
{ _id: notif._id },
{ $set: { emailNotified: true, emailNotifiedAt: new Date() } },
);
log.info({ notificationId, to: cfg.email.redirectTo ?? emailAddress }, 'email: sent');
}

73
src/index.ts Normal file
View file

@ -0,0 +1,73 @@
import { Worker } from 'bullmq';
import { loadConfig } from './config.js';
import { createLogger } from './logger.js';
import { connect, ensureIndexes } from './db.js';
import { createQueues, redisConnection, QUEUE_NAMES } from './queue.js';
import { createFcmClient, type FcmClient } from './fcm.js';
import { createMailer, type Mailer } from './mailer.js';
import { createPoller } from './poller.js';
import { processEmailJob, processFcmJob, type Deps } from './handlers.js';
import type { Channel, SendJob } from './types.js';
async function main(): Promise<void> {
const cfg = loadConfig();
const log = createLogger(cfg.logLevel, cfg.serviceName);
log.info(
{ mode: cfg.mode, owned: cfg.ownedChannels, killSwitch: cfg.killSwitch, db: cfg.dbName },
'tcef-notifications starting',
);
if (cfg.killSwitch) log.warn('KILL_SWITCH is ON — no sends will happen');
const repos = await connect(cfg);
await ensureIndexes(repos);
const queues = createQueues(cfg);
const connection = redisConnection(cfg);
// Real senders are only constructed when the mode may actually send, and only
// for owned channels — so dry-run/shadow never even needs the credentials.
const willSend = cfg.mode === 'canary' || cfg.mode === 'full';
let fcm: FcmClient | undefined;
let mailer: Mailer | undefined;
if (willSend && cfg.ownedChannels.includes('fcm')) fcm = createFcmClient(cfg);
if (willSend && cfg.ownedChannels.includes('email')) mailer = createMailer(cfg);
const deps: Deps = { cfg, repos, log, fcm, mailer };
const workers: Worker<SendJob>[] = [];
const handlerFor: Record<Channel, (id: string) => Promise<void>> = {
fcm: (id) => processFcmJob(deps, id),
email: (id) => processEmailJob(deps, id),
};
for (const channel of cfg.ownedChannels) {
const worker = new Worker<SendJob>(
QUEUE_NAMES[channel],
async (job) => handlerFor[channel](job.data.notificationId),
{ connection, concurrency: channel === 'email' ? 5 : 20 },
);
worker.on('failed', (job, err) => {
log.error({ channel, jobId: job?.id, err: err.message }, 'worker: job failed (dead-letter if attempts exhausted)');
});
workers.push(worker);
}
const poller = createPoller(cfg, repos, queues, log);
poller.start();
const shutdown = async (signal: string): Promise<void> => {
log.info({ signal }, 'shutting down');
poller.stop();
await Promise.all(workers.map((w) => w.close()));
await Promise.all(Object.values(queues).map((q) => q.close()));
await repos.close();
process.exit(0);
};
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
}
main().catch((err) => {
console.error('fatal:', err);
process.exit(1);
});

44
src/limits.ts Normal file
View file

@ -0,0 +1,44 @@
import type { Repos } from './db.js';
import type { Channel } from './types.js';
/**
* Per-user rate limit: count successful sends in the last 24h for a user on a
* channel by joining notification_sends notifications(userId). Returns true
* if the user is UNDER the limit (send allowed). 0 = unlimited.
*/
export async function underPerUserLimit(
repos: Repos,
userId: string,
channel: Channel,
perUserPerDay: number,
): Promise<boolean> {
if (perUserPerDay <= 0) return true;
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
// notification_sends has no userId; join via notifications._id.
const recentSends = await repos.sends
.find({ channel, createdAt: { $gte: since }, status: { $in: ['sent', 'dry-run'] } })
.project({ notificationId: 1 })
.toArray();
if (recentSends.length === 0) return true;
const ids = recentSends.map((r) => r.notificationId);
const count = await repos.notifications.countDocuments({ _id: { $in: ids }, userId });
return count < perUserPerDay;
}
/**
* Circuit breaker for a poll batch. Throws if the batch is larger than allowed,
* so the caller stops and alerts instead of flooding.
*/
export class BatchTooLargeError extends Error {
constructor(
public readonly size: number,
public readonly max: number,
) {
super(`Batch of ${size} exceeds circuit-breaker limit of ${max}; stopping and alerting.`);
this.name = 'BatchTooLargeError';
}
}
export function assertBatchWithinLimit(size: number, max: number): void {
if (max > 0 && size > max) throw new BatchTooLargeError(size, max);
}

14
src/logger.ts Normal file
View file

@ -0,0 +1,14 @@
import { pino } from 'pino';
export function createLogger(level: string, name: string) {
const pretty = process.stdout.isTTY;
return pino({
name,
level,
...(pretty
? { transport: { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard' } } }
: {}),
});
}
export type Logger = ReturnType<typeof createLogger>;

71
src/mailer.ts Normal file
View file

@ -0,0 +1,71 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import handlebars from 'handlebars';
import juice from 'juice';
import nodemailer, { type Transporter } from 'nodemailer';
import type { Config } from './config.js';
import type { EmailContent, Lang } from './messages.js';
const here = dirname(fileURLToPath(import.meta.url));
const templatesDir = join(here, 'templates');
type Compiled = { html: HandlebarsTemplateDelegate; text: HandlebarsTemplateDelegate };
const cache = new Map<string, Compiled>();
function loadTemplate(name: string, lang: Lang): Compiled {
const key = `${name}-${lang}`;
const cached = cache.get(key);
if (cached) return cached;
// Fallback chain: <name>-<lang> → <name>-es.
const langFile = (ext: string): string => {
const primary = join(templatesDir, `${name}-${lang}.${ext}`);
try {
readFileSync(primary);
return primary;
} catch {
return join(templatesDir, `${name}-es.${ext}`);
}
};
const htmlSrc = readFileSync(langFile('html'), 'utf8');
const textSrc = readFileSync(langFile('txt'), 'utf8');
const compiled: Compiled = {
html: handlebars.compile(htmlSrc),
text: handlebars.compile(textSrc),
};
cache.set(key, compiled);
return compiled;
}
export function renderEmail(name: string, content: EmailContent): { html: string; text: string } {
const tpl = loadTemplate(name, content.lang);
const html = juice(tpl.html(content.templateVars)); // inline CSS like the old juice() path
const text = tpl.text(content.templateVars);
return { html, text };
}
export interface Mailer {
send(to: string, content: EmailContent, templateName: string): Promise<void>;
}
export function createMailer(cfg: Config): Mailer {
if (!cfg.email.mailUrl) {
throw new Error('MAIL_URL not configured — cannot send email.');
}
const transporter: Transporter = nodemailer.createTransport(cfg.email.mailUrl);
return {
async send(to, content, templateName) {
const { html, text } = renderEmail(templateName, content);
// In canary/staging, MAIL_REDIRECT_TO overrides the real recipient.
const recipient = cfg.email.redirectTo ?? to;
await transporter.sendMail({
from: cfg.email.from,
to: recipient,
subject: content.subject,
text,
html,
...(cfg.email.redirectTo ? { headers: { 'X-Original-To': to } } : {}),
});
},
};
}

160
src/messages.ts Normal file
View file

@ -0,0 +1,160 @@
import { DateTime } from 'luxon';
import type { NotificationDoc } from './types.js';
/**
* i18n strings ported verbatim from
* todos-contra-el-fuego-web/public/locales/{es,en,gl}/common.json.
* Keeping them here (instead of importing the Meteor locale files) makes the
* service self-contained; the characterization test pins these values.
*/
export const I18N: Record<string, Record<'es' | 'en' | 'gl', string>> = {
'Alerta de fuego': { es: 'Alerta de fuego', en: 'Alert of fire', gl: 'Alerta de lume' },
AppName: { es: 'Tod@s contra el Fuego', en: 'All Against the Fire', gl: 'Tod@s contra o Lume' },
// {{when}} placeholder — filled with the localized long date.
fireDetectedAt: {
es: 'fuego detectado el {{when}}',
en: 'fire detected on {{when}}',
gl: 'lume detectado o {{when}}',
},
'Más información sobre este fuego': {
es: 'Más información sobre este fuego',
en: 'More information about this fire',
gl: 'Máis información sobre este lume',
},
};
export type Lang = 'es' | 'en' | 'gl';
export function normalizeLang(lang: string | undefined): Lang {
const l = (lang ?? 'es').slice(0, 2).toLowerCase();
return l === 'en' || l === 'gl' ? (l as Lang) : 'es';
}
export function t(key: keyof typeof I18N, lang: Lang, vars?: Record<string, string>): string {
let s = I18N[key]?.[lang] ?? I18N[key]?.es ?? key;
if (vars) {
for (const [k, v] of Object.entries(vars)) {
s = s.replaceAll(`{{${k}}}`, v);
}
}
return s;
}
/**
* Ported from imports/ui/components/NotificationsObserver/util.js:
* strip the leading "🔥 " and the trailing ":" node-red adds for Telegram.
*/
export function trim(message: string): string {
return message.replace(/^🔥 /, '').replace(/:$/, '');
}
/**
* Long localized date with timezone, equivalent to the old
* dateLongFormat = moment.tz(when, tz).format('LLLL (z)').
* Not compared byte-for-byte across systems; goal is a human-readable long date.
*/
export function dateLongFormat(when: Date, lang: Lang, zone = 'Europe/Madrid'): string {
const dt = DateTime.fromJSDate(when).setZone(zone).setLocale(lang);
// LLLL ≈ full weekday, day, month, year + time; append the zone abbreviation.
return `${dt.toFormat('cccc d LLLL yyyy HH:mm')} (${dt.toFormat('ZZZZ')})`;
}
/** Smart truncate, ported from subjectTruncate (send-email.js). */
export function subjectTruncate(s: string, n = 70, useWordBoundary = true): string {
if (s.length <= n) return s;
const sub = s.slice(0, n - 1);
return `${useWordBoundary ? sub.slice(0, sub.lastIndexOf(' ')) : sub}...`;
}
/** Google Static Maps URL, equivalent to the old imgUrl(). */
export function staticMapUrl(
lat: number,
lng: number,
opts: { key?: string; fireIconUrl?: string },
): string {
const params = new URLSearchParams({
center: `${lat},${lng}`,
size: '640x480',
zoom: '16',
maptype: 'hybrid',
language: 'es',
});
if (opts.key) params.set('key', opts.key);
const marker = opts.fireIconUrl
? `icon:${opts.fireIconUrl}|${lat},${lng}`
: `${lat},${lng}`;
params.set('markers', marker);
return `https://maps.googleapis.com/maps/api/staticmap?${params.toString()}`;
}
export interface PushPayload {
title: string;
body: string;
data: Record<string, string>;
collapseKey: string; // = notif._id hex (old `tag`)
clickAction: 'FLUTTER_NOTIFICATION_CLICK';
sound: 'default';
icon: 'launch_image';
}
/** Build the push payload, equivalent to the old gcm.Message. */
export function buildPush(notif: NotificationDoc, lang: Lang): PushPayload {
const id = notif._id.toHexString();
const body = trim(notif.content);
return {
title: t('Alerta de fuego', lang),
body,
collapseKey: id,
clickAction: 'FLUTTER_NOTIFICATION_CLICK',
sound: 'default',
icon: 'launch_image',
data: {
id,
description: body,
lat: String(notif.geo.coordinates[1]),
lon: String(notif.geo.coordinates[0]),
when: notif.when instanceof Date ? notif.when.toISOString() : String(notif.when),
subsId: notif.subsId ? notif.subsId.toHexString() : '',
sealed: notif.sealed,
},
};
}
export interface EmailContent {
subject: string;
templateVars: {
applicationName: string;
firstName: string;
message: string;
fireUrl: string;
img: string;
subsUrl: string;
};
lang: Lang;
}
/** Build the email content, equivalent to the old processNotif web branch. */
export function buildEmail(
notif: NotificationDoc,
lang: Lang,
firstName: string,
opts: { rootUrl: string; gmapsKey?: string; fireIconUrl?: string },
): EmailContent {
const lat = notif.geo.coordinates[1];
const lng = notif.geo.coordinates[0];
const when = dateLongFormat(notif.when, lang);
const message = `${trim(notif.content)} (${t('fireDetectedAt', lang, { when })}).`;
const base = opts.rootUrl.endsWith('/') ? opts.rootUrl : `${opts.rootUrl}/`;
return {
subject: subjectTruncate(message, 70),
lang,
templateVars: {
applicationName: t('AppName', lang),
firstName,
message,
fireUrl: `${base}fire/${notif.sealed}`,
img: staticMapUrl(lat, lng, { key: opts.gmapsKey, fireIconUrl: opts.fireIconUrl }),
subsUrl: `${base}subscriptions`,
},
};
}

35
src/mode.ts Normal file
View file

@ -0,0 +1,35 @@
import type { Config } from './config.js';
import type { NotificationDoc } from './types.js';
export type SendDecision =
| { send: true }
| { send: false; reason: 'kill-switch' | 'dry-run' | 'shadow' | 'not-in-canary' };
/**
* Decide whether an actual send should happen for this notification, given the
* operating mode and safeguards. This is the single gate every worker calls
* before touching FCM/SMTP.
*
* kill-switch never send (highest priority)
* dry-run never send (compute + log only)
* shadow never send (compare decisions with the old system)
* canary send only to the configured cohort (userIds / subsIds)
* full send to everyone
*/
export function decideSend(cfg: Config, notif: NotificationDoc): SendDecision {
if (cfg.killSwitch) return { send: false, reason: 'kill-switch' };
switch (cfg.mode) {
case 'dry-run':
return { send: false, reason: 'dry-run' };
case 'shadow':
return { send: false, reason: 'shadow' };
case 'canary': {
const inCohort =
cfg.canary.userIds.includes(notif.userId) ||
(notif.subsId ? cfg.canary.subsIds.includes(notif.subsId.toHexString()) : false);
return inCohort ? { send: true } : { send: false, reason: 'not-in-canary' };
}
case 'full':
return { send: true };
}
}

105
src/poller.ts Normal file
View file

@ -0,0 +1,105 @@
import type { Queue } from 'bullmq';
import type { Filter } from 'mongodb';
import type { Config } from './config.js';
import type { Repos } from './db.js';
import type { Logger } from './logger.js';
import { assertBatchWithinLimit, BatchTooLargeError } from './limits.js';
import { jobId } from './queue.js';
import type { Channel, NotificationDoc, SendJob } from './types.js';
/** Pending-doc filter for a channel — the CORRECT fields (old cron had a typo). */
export function pendingFilter(channel: Channel): Filter<NotificationDoc> {
return channel === 'fcm'
? { type: 'mobile', notified: { $ne: true } }
: { type: 'web', emailNotified: { $ne: true } };
}
export interface Poller {
runOnce(): Promise<Record<Channel, number>>;
start(): void;
stop(): void;
}
export function createPoller(
cfg: Config,
repos: Repos,
queues: Record<Channel, Queue<SendJob>>,
log: Logger,
): Poller {
let timer: NodeJS.Timeout | null = null;
let running = false;
async function pollChannel(channel: Channel): Promise<number> {
const docs = await repos.notifications
.find(pendingFilter(channel), { projection: { _id: 1 } })
.limit(cfg.poll.batchSize)
.toArray();
// Circuit breaker: an oversized batch means something is wrong — stop & alert.
assertBatchWithinLimit(docs.length, cfg.limits.maxPerBatch);
let enqueued = 0;
for (const doc of docs) {
const job: SendJob = { notificationId: doc._id.toHexString(), channel };
await queues[channel].add('send', job, { jobId: jobId(job) });
enqueued += 1;
}
if (enqueued > 0) {
await repos.notifications.updateMany(
{ _id: { $in: docs.map((d) => d._id) } },
{ $set: { claimedBy: cfg.serviceName, claimedAt: new Date() } },
);
}
return enqueued;
}
async function runOnce(): Promise<Record<Channel, number>> {
const result: Record<Channel, number> = { fcm: 0, email: 0 };
if (running) {
log.debug('poller: previous cycle still running, skipping');
return result;
}
running = true;
try {
for (const channel of cfg.ownedChannels) {
try {
result[channel] = await pollChannel(channel);
} catch (err) {
if (err instanceof BatchTooLargeError) {
log.fatal({ channel, size: err.size, max: err.max }, 'CIRCUIT BREAKER: batch too large — halting poller. Investigate before resuming.');
stop();
return result;
}
throw err;
}
}
if (result.fcm + result.email > 0) {
log.info({ enqueued: result, mode: cfg.mode, owned: cfg.ownedChannels }, 'poller: cycle complete');
}
} finally {
running = false;
}
return result;
}
function start(): void {
if (cfg.ownedChannels.length === 0) {
log.warn('poller: OWNED_CHANNELS is empty — nothing will be polled');
}
log.info({ intervalMs: cfg.poll.intervalMs, owned: cfg.ownedChannels }, 'poller: starting');
timer = setInterval(() => {
void runOnce().catch((err) => log.error({ err: String(err) }, 'poller: cycle error'));
}, cfg.poll.intervalMs);
// kick off immediately
void runOnce().catch((err) => log.error({ err: String(err) }, 'poller: initial cycle error'));
}
function stop(): void {
if (timer) {
clearInterval(timer);
timer = null;
}
}
return { runOnce, start, stop };
}

46
src/queue.ts Normal file
View file

@ -0,0 +1,46 @@
import { Queue, type ConnectionOptions, type QueueOptions } from 'bullmq';
import type { Config } from './config.js';
import type { Channel, SendJob } from './types.js';
export const QUEUE_NAMES: Record<Channel, string> = {
fcm: 'tcef-fcm',
email: 'tcef-email',
};
/**
* BullMQ connection options. We hand BullMQ plain options (not a shared ioredis
* instance) so it manages its own blocking/non-blocking connections and sets
* maxRetriesPerRequest:null on workers itself.
*/
export function redisConnection(cfg: Config): ConnectionOptions {
return {
host: cfg.redis.host,
port: cfg.redis.port,
password: cfg.redis.password,
db: cfg.redis.db,
};
}
export function createQueues(cfg: Config): Record<Channel, Queue<SendJob>> {
const opts: QueueOptions = {
connection: redisConnection(cfg),
defaultJobOptions: {
attempts: cfg.retries.attempts,
backoff: { type: 'exponential', delay: cfg.retries.backoffMs },
removeOnComplete: { count: 1000 },
removeOnFail: false, // keep failures = dead-letter for inspection
},
};
return {
fcm: new Queue<SendJob>(QUEUE_NAMES.fcm, opts),
email: new Queue<SendJob>(QUEUE_NAMES.email, opts),
};
}
/**
* Job id = `${channel}:${notificationId}` so BullMQ itself dedupes enqueues:
* re-polling the same pending doc will not create a second job.
*/
export function jobId(job: SendJob): string {
return `${job.channel}:${job.notificationId}`;
}

14
src/recipient.ts Normal file
View file

@ -0,0 +1,14 @@
import type { UserDoc } from './types.js';
/**
* Ported from imports/modules/get-email-of-user.js.
* Returns the recipient email only if verified. firstName from the profile.
* (The old code also consults an OAuth profile; for our channels the verified
* primary email is the operative case OAuth users still carry emails[].)
*/
export function getEmailOf(user: UserDoc): { emailAddress: string | null; firstName: string } {
const firstName = user.profile?.name?.first ?? '';
const primary = user.emails?.[0];
const emailAddress = primary && primary.verified ? primary.address : null;
return { emailAddress, firstName };
}

68
src/sends.ts Normal file
View file

@ -0,0 +1,68 @@
import { MongoServerError, type ObjectId } from 'mongodb';
import type { Repos } from './db.js';
import type { Channel, OperatingMode, SendRecord } from './types.js';
/**
* Persistent idempotency. `reserve()` inserts a placeholder into
* `notification_sends`; the unique index (notificationId, channel) makes a
* second attempt fail with a duplicate-key error. This survives process
* restarts and BullMQ replays Redis TTLs alone would not.
*
* Returns true if this caller won the reservation (should proceed), false if
* the send was already recorded (must NOT send again).
*/
export async function reserve(
repos: Repos,
notificationId: ObjectId,
channel: Channel,
mode: OperatingMode,
): Promise<boolean> {
const rec: SendRecord = {
notificationId,
channel,
status: 'sent', // optimistic; downgraded via markResult if it turns out otherwise
mode,
createdAt: new Date(),
};
try {
await repos.sends.insertOne(rec);
return true;
} catch (err) {
if (err instanceof MongoServerError && err.code === 11000) {
return false; // already reserved/sent — idempotent skip
}
throw err;
}
}
/** Update the outcome of a reserved send (does not affect idempotency). */
export async function markResult(
repos: Repos,
notificationId: ObjectId,
channel: Channel,
status: SendRecord['status'],
detail?: string,
): Promise<void> {
await repos.sends.updateOne(
{ notificationId, channel },
{ $set: { status, ...(detail ? { detail } : {}) } },
);
}
/** Release a reservation (e.g. when a send failed and should be retried later). */
export async function release(
repos: Repos,
notificationId: ObjectId,
channel: Channel,
): Promise<void> {
await repos.sends.deleteOne({ notificationId, channel });
}
export async function alreadySent(
repos: Repos,
notificationId: ObjectId,
channel: Channel,
): Promise<boolean> {
const existing = await repos.sends.findOne({ notificationId, channel });
return existing !== null;
}

View file

@ -0,0 +1,332 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>[{{applicationName}}] Alert of fire</title>
<style>
/* -------------------------------------
GLOBAL
A very basic CSS reset
------------------------------------- */
* {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
}
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
/* 1.6em * 14px = 22.4px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 22px;*/
}
/* Let's make sure all tables have defaults */
table td {
vertical-align: top;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
body {
background-color: #f6f6f6;
}
.body-wrap {
background-color: #f6f6f6;
width: 100%;
}
.container {
display: block !important;
max-width: 600px !important;
margin: 0 auto !important;
/* makes it centered */
clear: both !important;
}
.content {
max-width: 600px;
margin: 0 auto;
display: block;
padding: 20px;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background-color: #fff;
border: 1px solid #e9e9e9;
border-radius: 3px;
}
.content-wrap {
padding: 20px;
}
.content-block {
padding: 0 0 20px;
}
.header {
width: 100%;
margin-bottom: 20px;
}
.footer {
width: 100%;
clear: both;
color: #999;
padding: 20px;
}
.footer p, .footer a, .footer td {
color: #999;
font-size: 12px;
}
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1, h2, h3 {
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
color: #000;
margin: 40px 0 0;
line-height: 1.2em;
font-weight: 400;
}
h1 {
font-size: 32px;
font-weight: 500;
/* 1.2em * 32px = 38.4px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 38px;*/
}
h2 {
font-size: 24px;
/* 1.2em * 24px = 28.8px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 29px;*/
}
h3 {
font-size: 18px;
/* 1.2em * 18px = 21.6px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 22px;*/
}
h4 {
font-size: 14px;
font-weight: 600;
}
p, ul, ol {
margin-bottom: 10px;
font-weight: normal;
}
p li, ul li, ol li {
margin-left: 5px;
list-style-position: inside;
}
/* -------------------------------------
LINKS & BUTTONS
------------------------------------- */
a {
color: #348eda;
text-decoration: underline;
}
.btn-primary {
text-decoration: none;
color: #FFF;
background-color: #4285F4;
border: solid #4285F4;
border-width: 10px 20px;
line-height: 2em;
/* 2em * 14px = 28px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 28px;*/
font-weight: bold;
text-align: center;
cursor: pointer;
display: inline-block;
border-radius: 2px;
text-transform: capitalize;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.aligncenter {
text-align: center;
}
.alignright {
text-align: right;
}
.alignleft {
text-align: left;
}
.clear {
clear: both;
}
/* -------------------------------------
ALERTS
Change the class depending on warning email, good email or bad email
------------------------------------- */
.alert {
font-size: 16px;
color: #fff;
font-weight: 500;
padding: 20px;
text-align: center;
border-radius: 3px 3px 0 0;
}
.alert a {
color: #fff;
text-decoration: none;
font-weight: 500;
font-size: 16px;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
h1, h2, h3, h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
}
</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage">
<table class="body-wrap">
<tr>
<td></td>
<td class="container" width="600">
<div class="content">
<table class="main" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/UpdateAction">
<tr>
<td class="content-wrap">
<meta itemprop="name" content="Fire info"/>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block">
Hey, {{firstName}}!
</td>
</tr>
<tr>
<td class="content-block">
{{message}}
</td>
</tr>
<tr>
<td class="content-block">
<img src="{{img}}" width="640" height="480"/>
</td>
</tr>
<tr>
<td class="content-block" itemprop="handler" itemscope itemtype="http://schema.org/HttpActionHandler">
<a href="{{fireUrl}}" class="btn-primary" itemprop="url">More information about this fire</a>
</td>
</tr>
<tr>
<td class="content-block">
Is not it a forest fire? In the previous link you can tell us what kind of fire it is and help us improve our notifications.<br />
<br />
You can also add comments on that link if you have additional information about this fire (how to access that area, if you know how the fire originated, etc).
</td>
</tr>
<tr>
<td class="content-block">
Thanks! <br />
{{applicationName}} Team
</td>
</tr>
</table>
</td>
</tr>
</table>
<div class="footer">
<table width="100%">
<tr>
<td class="aligncenter content-block">Manage your <a href="{{subsUrl}}">subscriptions</a> to these fire alerts.</td>
</tr>
<tr>
<td class="aligncenter content-block">Follow <a href="http://twitter.com/TsContraElFuego">@TsContraElFuego</a> on Twitter.</td>
</tr>
</table>
</div></div>
</td>
<td></td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,14 @@
Hey, {{firstName}}!
{{message}}
More information about this fire: {{fireUrl}}
Isn't it a forest fire? In the previous link you can tell us what kind of fire it is and help us improve our notifications.
You can also add comments on that link if you have additional information about this fire (how to access that area, if you know how the fire originated, etc).
Thanks!
{{applicationName}} Team
Manage your subscriptions to these fire alerts: {{subsUrl}}

View file

@ -0,0 +1,332 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>[{{applicationName}}] Alerta de fuego</title>
<style>
/* -------------------------------------
GLOBAL
A very basic CSS reset
------------------------------------- */
* {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
}
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
/* 1.6em * 14px = 22.4px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 22px;*/
}
/* Let's make sure all tables have defaults */
table td {
vertical-align: top;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
body {
background-color: #f6f6f6;
}
.body-wrap {
background-color: #f6f6f6;
width: 100%;
}
.container {
display: block !important;
max-width: 600px !important;
margin: 0 auto !important;
/* makes it centered */
clear: both !important;
}
.content {
max-width: 600px;
margin: 0 auto;
display: block;
padding: 20px;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background-color: #fff;
border: 1px solid #e9e9e9;
border-radius: 3px;
}
.content-wrap {
padding: 20px;
}
.content-block {
padding: 0 0 20px;
}
.header {
width: 100%;
margin-bottom: 20px;
}
.footer {
width: 100%;
clear: both;
color: #999;
padding: 20px;
}
.footer p, .footer a, .footer td {
color: #999;
font-size: 12px;
}
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1, h2, h3 {
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
color: #000;
margin: 40px 0 0;
line-height: 1.2em;
font-weight: 400;
}
h1 {
font-size: 32px;
font-weight: 500;
/* 1.2em * 32px = 38.4px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 38px;*/
}
h2 {
font-size: 24px;
/* 1.2em * 24px = 28.8px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 29px;*/
}
h3 {
font-size: 18px;
/* 1.2em * 18px = 21.6px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 22px;*/
}
h4 {
font-size: 14px;
font-weight: 600;
}
p, ul, ol {
margin-bottom: 10px;
font-weight: normal;
}
p li, ul li, ol li {
margin-left: 5px;
list-style-position: inside;
}
/* -------------------------------------
LINKS & BUTTONS
------------------------------------- */
a {
color: #348eda;
text-decoration: underline;
}
.btn-primary {
text-decoration: none;
color: #FFF;
background-color: #4285F4;
border: solid #4285F4;
border-width: 10px 20px;
line-height: 2em;
/* 2em * 14px = 28px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 28px;*/
font-weight: bold;
text-align: center;
cursor: pointer;
display: inline-block;
border-radius: 2px;
text-transform: capitalize;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.aligncenter {
text-align: center;
}
.alignright {
text-align: right;
}
.alignleft {
text-align: left;
}
.clear {
clear: both;
}
/* -------------------------------------
ALERTS
Change the class depending on warning email, good email or bad email
------------------------------------- */
.alert {
font-size: 16px;
color: #fff;
font-weight: 500;
padding: 20px;
text-align: center;
border-radius: 3px 3px 0 0;
}
.alert a {
color: #fff;
text-decoration: none;
font-weight: 500;
font-size: 16px;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
h1, h2, h3, h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
}
</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage">
<table class="body-wrap">
<tr>
<td></td>
<td class="container" width="600">
<div class="content">
<table class="main" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/UpdateAction">
<tr>
<td class="content-wrap">
<meta itemprop="name" content="Fire info"/>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block">
Hola, {{firstName}}!
</td>
</tr>
<tr>
<td class="content-block">
{{message}}
</td>
</tr>
<tr>
<td class="content-block">
<img src="{{img}}" width="640" height="480"/>
</td>
</tr>
<tr>
<td class="content-block" itemprop="handler" itemscope itemtype="http://schema.org/HttpActionHandler">
<a href="{{fireUrl}}" class="btn-primary" itemprop="url">Más información sobre este fuego</a>
</td>
</tr>
<tr>
<td class="content-block">
¿No es un fuego forestal? En el enlace anterior puedes indicarnos de que tipo de fuego se trata y ayudarnos así a mejorar nuestras notificaciones.<br />
<br />
También puedes añadir comentarios en ese enlace si tienes información adicional sobre este fuego (cómo acceder a esa zona, si conoces cómo se originó el fuego, etc).<br />
</td>
</tr>
<tr>
<td class="content-block">
Gracias! <br />
El equipo de {{applicationName}}
</td>
</tr>
</table>
</td>
</tr>
</table>
<div class="footer">
<table width="100%">
<tr>
<td class="aligncenter content-block">Gestiona tus <a href="{{subsUrl}}">suscripciones</a> a estas alertas de fuegos.</td>
</tr>
<tr>
<td class="aligncenter content-block">Sigue a <a href="http://twitter.com/TsContraElFuego">@TsContraElFuego</a> en Twitter.</td>
</tr>
</table>
</div></div>
</td>
<td></td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,14 @@
Hola, {{firstName}}!
{{message}}
Más información sobre este fuego: {{fireUrl}}
¿No es un fuego forestal? En el enlace anterior puedes indicarnos de que tipo de fuego se trata y ayudarnos así a mejorar nuestras notificaciones.
También puedes añadir comentarios en ese enlace si tienes información adicional sobre este fuego (cómo acceder a esa zona, si conoces cómo se originó el fuego, etc).
Gracias!
El equipo de {{applicationName}}
Gestiona tus suscripciones a estas alertas de fuegos: {{subsUrl}}

57
src/types.ts Normal file
View file

@ -0,0 +1,57 @@
import type { ObjectId } from 'mongodb';
/** A `notifications` document, as written by node-red (see docs/legacy-behavior.md). */
export interface NotificationDoc {
_id: ObjectId;
userId: string;
subsId?: ObjectId;
content: string;
geo: { type: 'Point'; coordinates: [number, number] }; // [lng, lat]
type: 'mobile' | 'web';
notified?: boolean;
notifiedAt?: Date;
emailNotified?: boolean;
emailNotifiedAt?: Date;
when: Date;
sealed: string;
createdAt?: Date;
updatedAt?: Date;
// provenance set by our poller (invisible to the old system)
claimedBy?: string;
claimedAt?: Date;
}
export interface UserDoc {
_id: string;
lang?: string;
fireBaseToken?: string | null;
fireBaseTokenDeadAt?: Date;
profile?: { name?: { first?: string } };
emails?: Array<{ address: string; verified?: boolean }>;
services?: { password?: { bcrypt?: string } };
}
export type Channel = 'fcm' | 'email';
/** Maps a notification `type` to a channel. */
export function channelOfType(type: NotificationDoc['type']): Channel {
return type === 'mobile' ? 'fcm' : 'email';
}
/** Record in `notification_sends` — persistent idempotency (unique on notificationId+channel). */
export interface SendRecord {
notificationId: ObjectId;
channel: Channel;
status: 'sending' | 'sent' | 'skipped' | 'failed' | 'dry-run' | 'dead-token' | 'no-recipient';
mode: OperatingMode;
detail?: string;
createdAt: Date;
}
export type OperatingMode = 'dry-run' | 'shadow' | 'canary' | 'full';
/** A queued send job (payload persisted in Redis by BullMQ). */
export interface SendJob {
notificationId: string; // ObjectId hex
channel: Channel;
}

32
test/config.test.ts Normal file
View file

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { loadConfig } from '../src/config.js';
const base = { MONGO_URL: 'mongodb://localhost/fuegos', CONFIG_FILE: '/nonexistent.json' };
describe('loadConfig', () => {
it('defaults to the safest mode (dry-run) with no channels owned', () => {
const cfg = loadConfig({ ...base } as NodeJS.ProcessEnv);
expect(cfg.mode).toBe('dry-run');
expect(cfg.ownedChannels).toEqual([]);
expect(cfg.killSwitch).toBe(false);
});
it('requires MONGO_URL', () => {
expect(() => loadConfig({ CONFIG_FILE: '/nonexistent.json' } as NodeJS.ProcessEnv)).toThrow(/MONGO_URL/);
});
it('rejects an invalid mode', () => {
expect(() => loadConfig({ ...base, MODE: 'nuke' } as NodeJS.ProcessEnv)).toThrow(/Invalid MODE/);
});
it('parses OWNED_CHANNELS and rejects unknown channels', () => {
expect(loadConfig({ ...base, OWNED_CHANNELS: 'fcm,email' } as NodeJS.ProcessEnv).ownedChannels).toEqual(['fcm', 'email']);
expect(() => loadConfig({ ...base, OWNED_CHANNELS: 'fcm,telegram' } as NodeJS.ProcessEnv)).toThrow(/Invalid channel/);
});
it('parses KILL_SWITCH and canary lists', () => {
const cfg = loadConfig({ ...base, KILL_SWITCH: '1', CANARY_USER_IDS: 'a, b ,c', MODE: 'canary' } as NodeJS.ProcessEnv);
expect(cfg.killSwitch).toBe(true);
expect(cfg.canary.userIds).toEqual(['a', 'b', 'c']);
});
});

218
test/handlers.test.ts Normal file
View file

@ -0,0 +1,218 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type { MongoMemoryServer } from 'mongodb-memory-server';
import type { Repos } from '../src/db.js';
import type { Config } from '../src/config.js';
import { processEmailJob, processFcmJob, type Deps } from '../src/handlers.js';
import { aNotif, aUser, fakeFcm, fakeMailer, silentLog, startMongo } from './helpers.js';
let server: MongoMemoryServer;
let repos: Repos;
let baseCfg: Config;
beforeAll(async () => {
const s = await startMongo();
server = s.server;
repos = s.repos;
baseCfg = s.cfg;
});
afterAll(async () => {
await repos.close();
await server.stop();
});
beforeEach(async () => {
await repos.notifications.deleteMany({});
await repos.users.deleteMany({});
await repos.sends.deleteMany({});
});
function deps(over: Partial<Deps> = {}): Deps {
return { cfg: baseCfg, repos, log: silentLog, ...over };
}
describe('FCM handler', () => {
it('sends push and marks the doc notified (full mode)', async () => {
const notif = aNotif();
await repos.notifications.insertOne(notif);
await repos.users.insertOne(aUser());
const fcm = fakeFcm({ ok: true, messageId: 'mid-1' });
await processFcmJob(deps({ fcm }), notif._id.toHexString());
expect(fcm.calls).toHaveLength(1);
expect(fcm.calls[0]!.token).toBe('tok-abc');
const after = await repos.notifications.findOne({ _id: notif._id });
expect(after?.notified).toBe(true);
expect(after?.notifiedAt).toBeInstanceOf(Date);
const send = await repos.sends.findOne({ notificationId: notif._id, channel: 'fcm' });
expect(send?.status).toBe('sent');
});
it('idempotent across a simulated restart: never double-sends', async () => {
const notif = aNotif();
await repos.notifications.insertOne(notif);
await repos.users.insertOne(aUser());
const fcm = fakeFcm({ ok: true, messageId: 'mid-1' });
// First run sends and marks notified.
await processFcmJob(deps({ fcm }), notif._id.toHexString());
// Simulate a replay/restart: the doc is already notified -> guard skips.
await processFcmJob(deps({ fcm }), notif._id.toHexString());
// Even if the notified flag were somehow cleared, the send record blocks it.
await repos.notifications.updateOne({ _id: notif._id }, { $unset: { notified: '' } });
await processFcmJob(deps({ fcm }), notif._id.toHexString());
expect(fcm.calls).toHaveLength(1); // only ONE real send, ever
});
it('dry-run does not send and does not mark the doc', async () => {
const notif = aNotif();
await repos.notifications.insertOne(notif);
await repos.users.insertOne(aUser());
const fcm = fakeFcm({ ok: true, messageId: 'x' });
await processFcmJob(deps({ cfg: { ...baseCfg, mode: 'dry-run' }, fcm }), notif._id.toHexString());
expect(fcm.calls).toHaveLength(0);
const after = await repos.notifications.findOne({ _id: notif._id });
expect(after?.notified).toBeUndefined();
expect(await repos.sends.countDocuments({})).toBe(0); // no reservation in dry-run
});
it('kill switch stops sending', async () => {
const notif = aNotif();
await repos.notifications.insertOne(notif);
await repos.users.insertOne(aUser());
const fcm = fakeFcm({ ok: true, messageId: 'x' });
await processFcmJob(deps({ cfg: { ...baseCfg, killSwitch: true }, fcm }), notif._id.toHexString());
expect(fcm.calls).toHaveLength(0);
});
it('canary sends only to the cohort', async () => {
const inCohort = aNotif({ userId: 'u1' });
const outCohort = aNotif({ userId: 'u2', subsId: undefined });
await repos.notifications.insertMany([inCohort, outCohort]);
await repos.users.insertMany([aUser({ _id: 'u1' }), aUser({ _id: 'u2' })]);
const fcm = fakeFcm({ ok: true, messageId: 'x' });
const cfg = { ...baseCfg, mode: 'canary' as const, canary: { userIds: ['u1'], subsIds: [] } };
await processFcmJob(deps({ cfg, fcm }), inCohort._id.toHexString());
await processFcmJob(deps({ cfg, fcm }), outCohort._id.toHexString());
expect(fcm.calls).toHaveLength(1);
expect(fcm.calls[0]!.token).toBe('tok-abc');
});
it('purges dead FCM tokens and does not retry', async () => {
const notif = aNotif();
await repos.notifications.insertOne(notif);
await repos.users.insertOne(aUser());
const fcm = fakeFcm({ ok: false, deadToken: true, error: 'messaging/registration-token-not-registered' });
await processFcmJob(deps({ fcm }), notif._id.toHexString());
const user = await repos.users.findOne({ _id: 'u1' });
expect(user?.fireBaseToken).toBeNull();
expect(user?.fireBaseTokenDeadAt).toBeInstanceOf(Date);
const send = await repos.sends.findOne({ notificationId: notif._id, channel: 'fcm' });
expect(send?.status).toBe('dead-token'); // recorded -> won't retry
const after = await repos.notifications.findOne({ _id: notif._id });
expect(after?.notified).toBeUndefined(); // not marked as notified
});
it('transient error releases the reservation so it can retry', async () => {
const notif = aNotif();
await repos.notifications.insertOne(notif);
await repos.users.insertOne(aUser());
const fcm = fakeFcm({ ok: false, deadToken: false, error: 'UNAVAILABLE' });
await expect(processFcmJob(deps({ fcm }), notif._id.toHexString())).rejects.toThrow();
// reservation released -> a later retry can proceed
expect(await repos.sends.countDocuments({ notificationId: notif._id })).toBe(0);
const fcm2 = fakeFcm({ ok: true, messageId: 'ok' });
await processFcmJob(deps({ fcm: fcm2 }), notif._id.toHexString());
expect(fcm2.calls).toHaveLength(1);
});
it('skips users without a fireBaseToken', async () => {
const notif = aNotif();
await repos.notifications.insertOne(notif);
await repos.users.insertOne(aUser({ fireBaseToken: null }));
const fcm = fakeFcm({ ok: true, messageId: 'x' });
await processFcmJob(deps({ fcm }), notif._id.toHexString());
expect(fcm.calls).toHaveLength(0);
const after = await repos.notifications.findOne({ _id: notif._id });
expect(after?.notified).toBeUndefined();
});
it('enforces the per-user/day limit', async () => {
await repos.users.insertOne(aUser());
const cfg = { ...baseCfg, limits: { ...baseCfg.limits, perUserPerDay: 2 } };
const fcm = fakeFcm({ ok: true, messageId: 'x' });
// Three pending notifs for the same user.
const notifs = [aNotif(), aNotif(), aNotif()];
await repos.notifications.insertMany(notifs);
for (const n of notifs) {
await processFcmJob(deps({ cfg, fcm }), n._id.toHexString());
}
expect(fcm.calls).toHaveLength(2); // third blocked by limit
});
});
describe('Email handler', () => {
it('sends email and marks emailNotified', async () => {
const notif = aNotif({ type: 'web' });
await repos.notifications.insertOne(notif);
await repos.users.insertOne(aUser());
const mailer = fakeMailer();
await processEmailJob(deps({ mailer }), notif._id.toHexString());
expect(mailer.calls).toHaveLength(1);
expect(mailer.calls[0]!.to).toBe('ana@example.com');
expect(mailer.calls[0]!.content.subject.length).toBeGreaterThan(0);
const after = await repos.notifications.findOne({ _id: notif._id });
expect(after?.emailNotified).toBe(true);
});
it('removes the doc when there is no verified recipient (full mode)', async () => {
const notif = aNotif({ type: 'web' });
await repos.notifications.insertOne(notif);
await repos.users.insertOne(aUser({ emails: [{ address: 'ana@example.com', verified: false }] }));
const mailer = fakeMailer();
await processEmailJob(deps({ mailer }), notif._id.toHexString());
expect(mailer.calls).toHaveLength(0);
expect(await repos.notifications.findOne({ _id: notif._id })).toBeNull(); // removed
});
it('does not remove the doc for missing recipient in shadow mode', async () => {
const notif = aNotif({ type: 'web' });
await repos.notifications.insertOne(notif);
await repos.users.insertOne(aUser({ emails: [{ address: 'x@y', verified: false }] }));
const mailer = fakeMailer();
await processEmailJob(deps({ cfg: { ...baseCfg, mode: 'shadow' }, mailer }), notif._id.toHexString());
expect(await repos.notifications.findOne({ _id: notif._id })).not.toBeNull();
});
it('transient SMTP error releases reservation and rethrows', async () => {
const notif = aNotif({ type: 'web' });
await repos.notifications.insertOne(notif);
await repos.users.insertOne(aUser());
const mailer = fakeMailer({ fail: true });
await expect(processEmailJob(deps({ mailer }), notif._id.toHexString())).rejects.toThrow();
expect(await repos.sends.countDocuments({ notificationId: notif._id })).toBe(0);
});
});

89
test/helpers.ts Normal file
View file

@ -0,0 +1,89 @@
import { MongoMemoryServer } from 'mongodb-memory-server';
import { pino } from 'pino';
import { ObjectId } from 'mongodb';
import { connect, ensureIndexes, type Repos } from '../src/db.js';
import type { Config } from '../src/config.js';
import type { FcmClient, FcmSendResult } from '../src/fcm.js';
import type { Mailer } from '../src/mailer.js';
import type { EmailContent, PushPayload } from '../src/messages.js';
import type { NotificationDoc, UserDoc } from '../src/types.js';
export const silentLog = pino({ level: 'silent' });
export async function startMongo(): Promise<{ server: MongoMemoryServer; repos: Repos; cfg: Config }> {
const server = await MongoMemoryServer.create();
const cfg = testCfg({ mongoUrl: server.getUri(), dbName: 'fuegos' });
const repos = await connect(cfg);
await ensureIndexes(repos);
return { server, repos, cfg };
}
export function testCfg(over: Partial<Config> = {}): Config {
return {
mode: 'full',
killSwitch: false,
ownedChannels: ['fcm', 'email'],
mongoUrl: 'mongodb://x',
dbName: 'fuegos',
redis: { host: 'x', port: 6379, db: 0 },
poll: { intervalMs: 1000, batchSize: 500 },
limits: { perUserPerDay: 20, maxPerBatch: 2000 },
canary: { userIds: [], subsIds: [] },
retries: { attempts: 3, backoffMs: 100 },
fcm: {},
email: { from: 'test <t@x>' },
rootUrl: 'https://fuegos.comunes.org/',
gmaps: {},
logLevel: 'silent',
serviceName: 'tcef-notifications',
...over,
};
}
export function aNotif(over: Partial<NotificationDoc> = {}): NotificationDoc {
return {
_id: new ObjectId(),
userId: 'u1',
subsId: new ObjectId(),
content: '🔥 Fuego cerca:',
geo: { type: 'Point', coordinates: [-8.5, 42.3] },
type: 'mobile',
when: new Date('2026-07-12T18:30:00Z'),
sealed: 'fire-1',
...over,
};
}
export function aUser(over: Partial<UserDoc> = {}): UserDoc {
return {
_id: 'u1',
lang: 'es',
fireBaseToken: 'tok-abc',
profile: { name: { first: 'Ana' } },
emails: [{ address: 'ana@example.com', verified: true }],
...over,
};
}
/** Recording FCM fake with programmable result. */
export function fakeFcm(result: FcmSendResult | (() => FcmSendResult)): FcmClient & { calls: Array<{ token: string; payload: PushPayload }> } {
const calls: Array<{ token: string; payload: PushPayload }> = [];
return {
calls,
async send(token, payload) {
calls.push({ token, payload });
return typeof result === 'function' ? result() : result;
},
};
}
export function fakeMailer(opts: { fail?: boolean } = {}): Mailer & { calls: Array<{ to: string; content: EmailContent }> } {
const calls: Array<{ to: string; content: EmailContent }> = [];
return {
calls,
async send(to, content) {
if (opts.fail) throw new Error('smtp down');
calls.push({ to, content });
},
};
}

36
test/mailer.test.ts Normal file
View file

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { ObjectId } from 'mongodb';
import { renderEmail } from '../src/mailer.js';
import { buildEmail } from '../src/messages.js';
import type { NotificationDoc } from '../src/types.js';
const notif: NotificationDoc = {
_id: new ObjectId(),
userId: 'u1',
content: '🔥 Incendio en Ourense:',
geo: { type: 'Point', coordinates: [-7.86, 42.34] },
type: 'web',
when: new Date('2026-07-12T18:30:00Z'),
sealed: 'fire-xyz',
};
describe('renderEmail with ported new-fire templates', () => {
it('renders es html + text with the message and links', () => {
const content = buildEmail(notif, 'es', 'Ana', { rootUrl: 'https://fuegos.comunes.org/' });
const { html, text } = renderEmail('new-fire', content);
expect(html).toContain('Ana');
expect(html).toContain('Incendio en Ourense');
expect(html).toContain('https://fuegos.comunes.org/fire/fire-xyz');
// juice should inline styles (style attribute present on elements)
expect(html).toMatch(/style=/);
expect(text).toContain('Ana');
expect(text).toContain('https://fuegos.comunes.org/fire/fire-xyz');
});
it('renders en template', () => {
const content = buildEmail(notif, 'en', 'Bob', { rootUrl: 'https://fires.comunes.org/' });
const { html } = renderEmail('new-fire', content);
expect(html).toContain('Bob');
expect(html).toContain('fires.comunes.org/fire/fire-xyz');
});
});

137
test/messages.test.ts Normal file
View file

@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest';
import { ObjectId } from 'mongodb';
import {
buildEmail,
buildPush,
dateLongFormat,
normalizeLang,
staticMapUrl,
subjectTruncate,
t,
trim,
} from '../src/messages.js';
import type { NotificationDoc } from '../src/types.js';
function fakeNotif(overrides: Partial<NotificationDoc> = {}): NotificationDoc {
return {
_id: new ObjectId('0123456789abcdef01234567'),
userId: 'user-1',
subsId: new ObjectId('aaaaaaaaaaaaaaaaaaaaaaaa'),
content: '🔥 Fuego cerca de tu zona en Galicia:',
geo: { type: 'Point', coordinates: [-8.5, 42.3] }, // [lng, lat]
type: 'mobile',
when: new Date('2026-07-12T18:30:00Z'),
sealed: 'fire-abc',
...overrides,
};
}
describe('trim (ported from NotificationsObserver/util.js)', () => {
it('strips leading 🔥 and trailing colon', () => {
expect(trim('🔥 Fuego cerca:')).toBe('Fuego cerca');
});
it('leaves other text intact', () => {
expect(trim('Fuego en el monte')).toBe('Fuego en el monte');
});
});
describe('i18n strings (pinned from Meteor locales)', () => {
it('title per language', () => {
expect(t('Alerta de fuego', 'es')).toBe('Alerta de fuego');
expect(t('Alerta de fuego', 'en')).toBe('Alert of fire');
});
it('AppName per language', () => {
expect(t('AppName', 'es')).toBe('Tod@s contra el Fuego');
expect(t('AppName', 'en')).toBe('All Against the Fire');
});
it('fireDetectedAt interpolates {{when}}', () => {
expect(t('fireDetectedAt', 'es', { when: 'X' })).toBe('fuego detectado el X');
expect(t('fireDetectedAt', 'en', { when: 'X' })).toBe('fire detected on X');
});
});
describe('normalizeLang', () => {
it('maps unknown langs to es', () => {
expect(normalizeLang('fr')).toBe('es');
expect(normalizeLang(undefined)).toBe('es');
expect(normalizeLang('en-US')).toBe('en');
expect(normalizeLang('gl')).toBe('gl');
});
});
describe('subjectTruncate (ported)', () => {
it('keeps short strings', () => {
expect(subjectTruncate('short', 70)).toBe('short');
});
it('truncates on word boundary with ellipsis', () => {
const s = 'a'.repeat(40) + ' ' + 'b'.repeat(40);
const out = subjectTruncate(s, 70);
expect(out.endsWith('...')).toBe(true);
expect(out.length).toBeLessThanOrEqual(73);
});
});
describe('buildPush (equivalent to old gcm.Message)', () => {
it('produces title/body and full data payload', () => {
const p = buildPush(fakeNotif(), 'es');
expect(p.title).toBe('Alerta de fuego');
expect(p.body).toBe('Fuego cerca de tu zona en Galicia'); // trimmed
expect(p.clickAction).toBe('FLUTTER_NOTIFICATION_CLICK');
expect(p.sound).toBe('default');
expect(p.icon).toBe('launch_image');
expect(p.collapseKey).toBe('0123456789abcdef01234567');
expect(p.data.id).toBe('0123456789abcdef01234567');
expect(p.data.description).toBe('Fuego cerca de tu zona en Galicia');
expect(p.data.lat).toBe('42.3'); // coordinates[1]
expect(p.data.lon).toBe('-8.5'); // coordinates[0]
expect(p.data.sealed).toBe('fire-abc');
expect(p.data.subsId).toBe('aaaaaaaaaaaaaaaaaaaaaaaa');
});
it('handles missing subsId', () => {
const p = buildPush(fakeNotif({ subsId: undefined }), 'en');
expect(p.data.subsId).toBe('');
expect(p.title).toBe('Alert of fire');
});
});
describe('buildEmail (equivalent to old processNotif web branch)', () => {
it('builds subject, message and template vars', () => {
const notif = fakeNotif({ type: 'web', content: '🔥 Incendio en A Coruña:' });
const e = buildEmail(notif, 'es', 'Ana', {
rootUrl: 'https://fuegos.comunes.org/',
gmapsKey: 'KEY',
fireIconUrl: 'https://x/icon.png',
});
expect(e.templateVars.firstName).toBe('Ana');
expect(e.templateVars.applicationName).toBe('Tod@s contra el Fuego');
expect(e.templateVars.message).toMatch(/^Incendio en A Coruña \(fuego detectado el .+\)\.$/);
expect(e.templateVars.fireUrl).toBe('https://fuegos.comunes.org/fire/fire-abc');
expect(e.templateVars.subsUrl).toBe('https://fuegos.comunes.org/subscriptions');
expect(e.templateVars.img).toContain('maps.googleapis.com');
expect(e.subject.length).toBeLessThanOrEqual(73);
});
it('normalizes rootUrl without trailing slash', () => {
const e = buildEmail(fakeNotif({ type: 'web' }), 'en', 'Bob', {
rootUrl: 'https://fires.comunes.org',
});
expect(e.templateVars.fireUrl).toBe('https://fires.comunes.org/fire/fire-abc');
});
});
describe('staticMapUrl', () => {
it('includes fire icon marker and coordinates', () => {
const url = staticMapUrl(42.3, -8.5, { key: 'K', fireIconUrl: 'https://x/i.png' });
expect(url).toContain('center=42.3%2C-8.5');
expect(url).toContain('maptype=hybrid');
expect(url).toContain('zoom=16');
expect(url).toContain('key=K');
});
});
describe('dateLongFormat', () => {
it('returns a localized long date with zone', () => {
const s = dateLongFormat(new Date('2026-07-12T18:30:00Z'), 'es', 'Europe/Madrid');
expect(s).toMatch(/2026/);
expect(s).toMatch(/\(.+\)$/); // zone in parens
});
});

76
test/mode.test.ts Normal file
View file

@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { ObjectId } from 'mongodb';
import { decideSend } from '../src/mode.js';
import { assertBatchWithinLimit, BatchTooLargeError } from '../src/limits.js';
import type { Config } from '../src/config.js';
import type { NotificationDoc } from '../src/types.js';
function baseCfg(over: Partial<Config> = {}): Config {
return {
mode: 'full',
killSwitch: false,
ownedChannels: ['fcm'],
mongoUrl: 'mongodb://x',
dbName: 'fuegos',
redis: { host: 'x', port: 6379, db: 0 },
poll: { intervalMs: 1000, batchSize: 100 },
limits: { perUserPerDay: 20, maxPerBatch: 2000 },
canary: { userIds: [], subsIds: [] },
retries: { attempts: 3, backoffMs: 100 },
fcm: {},
email: { from: 'x' },
rootUrl: 'https://x/',
gmaps: {},
logLevel: 'silent',
serviceName: 'test',
...over,
};
}
const notif: NotificationDoc = {
_id: new ObjectId(),
userId: 'u1',
subsId: new ObjectId('bbbbbbbbbbbbbbbbbbbbbbbb'),
content: 'x',
geo: { type: 'Point', coordinates: [0, 0] },
type: 'mobile',
when: new Date(),
sealed: 's',
};
describe('decideSend', () => {
it('kill switch overrides every mode', () => {
for (const mode of ['dry-run', 'shadow', 'canary', 'full'] as const) {
const d = decideSend(baseCfg({ mode, killSwitch: true }), notif);
expect(d).toEqual({ send: false, reason: 'kill-switch' });
}
});
it('dry-run never sends', () => {
expect(decideSend(baseCfg({ mode: 'dry-run' }), notif)).toEqual({ send: false, reason: 'dry-run' });
});
it('shadow never sends', () => {
expect(decideSend(baseCfg({ mode: 'shadow' }), notif)).toEqual({ send: false, reason: 'shadow' });
});
it('canary sends only to cohort by userId', () => {
expect(decideSend(baseCfg({ mode: 'canary', canary: { userIds: ['u1'], subsIds: [] } }), notif)).toEqual({ send: true });
expect(decideSend(baseCfg({ mode: 'canary', canary: { userIds: ['other'], subsIds: [] } }), notif)).toEqual({ send: false, reason: 'not-in-canary' });
});
it('canary sends to cohort by subsId', () => {
expect(decideSend(baseCfg({ mode: 'canary', canary: { userIds: [], subsIds: ['bbbbbbbbbbbbbbbbbbbbbbbb'] } }), notif)).toEqual({ send: true });
});
it('full always sends', () => {
expect(decideSend(baseCfg({ mode: 'full' }), notif)).toEqual({ send: true });
});
});
describe('circuit breaker', () => {
it('throws when batch exceeds max', () => {
expect(() => assertBatchWithinLimit(2001, 2000)).toThrow(BatchTooLargeError);
});
it('passes when within max', () => {
expect(() => assertBatchWithinLimit(2000, 2000)).not.toThrow();
});
it('unlimited when max is 0', () => {
expect(() => assertBatchWithinLimit(999999, 0)).not.toThrow();
});
});

93
test/poller.test.ts Normal file
View file

@ -0,0 +1,93 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type { Queue } from 'bullmq';
import type { MongoMemoryServer } from 'mongodb-memory-server';
import type { Repos } from '../src/db.js';
import type { Config } from '../src/config.js';
import { createPoller } from '../src/poller.js';
import { pendingFilter } from '../src/poller.js';
import type { Channel, SendJob } from '../src/types.js';
import { aNotif, silentLog, startMongo } from './helpers.js';
let server: MongoMemoryServer;
let repos: Repos;
let baseCfg: Config;
beforeAll(async () => {
const s = await startMongo();
server = s.server;
repos = s.repos;
baseCfg = s.cfg;
});
afterAll(async () => {
await repos.close();
await server.stop();
});
beforeEach(async () => {
await repos.notifications.deleteMany({});
});
/** Fake queue recording add() calls. */
function fakeQueues() {
const added: Record<Channel, SendJob[]> = { fcm: [], email: [] };
const make = (ch: Channel) =>
({
async add(_name: string, job: SendJob) {
added[ch].push(job);
},
}) as unknown as Queue<SendJob>;
return { queues: { fcm: make('fcm'), email: make('email') } as Record<Channel, Queue<SendJob>>, added };
}
describe('pendingFilter', () => {
it('uses the CORRECT field names (old cron had a typo)', () => {
expect(pendingFilter('fcm')).toEqual({ type: 'mobile', notified: { $ne: true } });
expect(pendingFilter('email')).toEqual({ type: 'web', emailNotified: { $ne: true } });
});
});
describe('poller.runOnce', () => {
it('enqueues only pending docs of owned channels and claims them', async () => {
await repos.notifications.insertMany([
aNotif({ type: 'mobile' }), // pending fcm
aNotif({ type: 'mobile', notified: true }), // already done
aNotif({ type: 'web' }), // pending email
aNotif({ type: 'web', emailNotified: true }), // already done
]);
const { queues, added } = fakeQueues();
const poller = createPoller(baseCfg, repos, queues, silentLog);
const res = await poller.runOnce();
expect(res).toEqual({ fcm: 1, email: 1 });
expect(added.fcm).toHaveLength(1);
expect(added.email).toHaveLength(1);
// claimedBy provenance set
expect(await repos.notifications.countDocuments({ claimedBy: baseCfg.serviceName })).toBe(2);
});
it('only polls owned channels', async () => {
await repos.notifications.insertMany([aNotif({ type: 'mobile' }), aNotif({ type: 'web' })]);
const { queues, added } = fakeQueues();
const cfg = { ...baseCfg, ownedChannels: ['fcm'] as Channel[] };
const poller = createPoller(cfg, repos, queues, silentLog);
const res = await poller.runOnce();
expect(res.fcm).toBe(1);
expect(added.email).toHaveLength(0);
});
it('circuit breaker halts on oversized batch', async () => {
const many = Array.from({ length: 5 }, () => aNotif({ type: 'mobile' }));
await repos.notifications.insertMany(many);
const { queues, added } = fakeQueues();
const cfg = { ...baseCfg, ownedChannels: ['fcm'] as Channel[], limits: { ...baseCfg.limits, maxPerBatch: 3 } };
const poller = createPoller(cfg, repos, queues, silentLog);
const res = await poller.runOnce();
// Batch of 5 > max 3 -> nothing enqueued, poller halted.
expect(res.fcm).toBe(0);
expect(added.fcm).toHaveLength(0);
});
});

23
tsconfig.json Normal file
View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": false,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"declaration": false,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

10
vitest.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
environment: 'node',
include: ['test/**/*.test.ts'],
testTimeout: 15000,
},
});