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:
commit
d20e168c90
39 changed files with 8378 additions and 0 deletions
50
.env.example
Normal file
50
.env.example
Normal 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
14
.gitignore
vendored
Normal 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
69
README.md
Normal 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
17
config.example.json
Normal 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"
|
||||||
|
}
|
||||||
22
deploy/pm2-tcef-notifications.json
Normal file
22
deploy/pm2-tcef-notifications.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
31
deploy/tcef-notifications.service
Normal file
31
deploy/tcef-notifications.service
Normal 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
161
docs/legacy-behavior.md
Normal 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
119
docs/rollout.md
Normal 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
5317
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
38
package.json
Normal file
38
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
scripts/copy-templates.mjs
Normal file
6
scripts/copy-templates.mjs
Normal 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
159
src/config.ts
Normal 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
47
src/db.ts
Normal 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
59
src/fcm.ts
Normal 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
176
src/handlers.ts
Normal 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
73
src/index.ts
Normal 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
44
src/limits.ts
Normal 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
14
src/logger.ts
Normal 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
71
src/mailer.ts
Normal 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
160
src/messages.ts
Normal 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
35
src/mode.ts
Normal 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
105
src/poller.ts
Normal 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
46
src/queue.ts
Normal 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
14
src/recipient.ts
Normal 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
68
src/sends.ts
Normal 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;
|
||||||
|
}
|
||||||
332
src/templates/new-fire-en.html
Normal file
332
src/templates/new-fire-en.html
Normal 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>
|
||||||
14
src/templates/new-fire-en.txt
Normal file
14
src/templates/new-fire-en.txt
Normal 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}}
|
||||||
332
src/templates/new-fire-es.html
Normal file
332
src/templates/new-fire-es.html
Normal 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>
|
||||||
14
src/templates/new-fire-es.txt
Normal file
14
src/templates/new-fire-es.txt
Normal 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
57
src/types.ts
Normal 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
32
test/config.test.ts
Normal 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
218
test/handlers.test.ts
Normal 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
89
test/helpers.ts
Normal 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
36
test/mailer.test.ts
Normal 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
137
test/messages.test.ts
Normal 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
76
test/mode.test.ts
Normal 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
93
test/poller.test.ts
Normal 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
23
tsconfig.json
Normal 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
10
vitest.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: false,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
testTimeout: 15000,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue