Fase 1b: nucleo del matcher geoespacial (caracterizado + testeado)

Caracterizacion del matching viejo de node-red en docs/legacy-matching.md
(reglas: candidatas geo $near 1000km, filtro fino geolib dist/1000<=sub.distance,
dedupe 500m, content i18n kmnasa/kmvecinal con 'км' cirilico verbatim, sealed via
Iron). Modulos:

- matcher/content.ts: i18n kmnasa/kmvecinal + km redondeado (Math.round dist/1000*10/10)
- matcher/geo.ts: geolib.getDistance (identico al viejo) + isHit
- matcher/seal.ts: @hapi/iron (Fe26.2) — COMPATIBLE con el iron@5 de la web
  (verificado: sello en servicio -> unseal en web round-trip OK)
- matcher/matcher.ts: matchFire(fire, ctx) -> docs notifications identicos a
  node-red; dedupe 500m + 1 notif/usuario/fuego; solo audiencia web/mobile
- deps: @hapi/iron, geolib

63 tests en verde (incluye interop CJS/ESM en dist). MongoDB 3.2 -> ingesta por
polling (sin change streams), pendiente de cablear.
This commit is contained in:
vjrj 2026-07-13 17:58:43 +02:00
parent 4f8d866226
commit b3aeb6108f
9 changed files with 507 additions and 0 deletions

122
docs/legacy-matching.md Normal file
View file

@ -0,0 +1,122 @@
# Comportamiento del matching viejo (fase 1b — caracterización)
> Fuente: subflow "send notifications" de node-red en
> `todos-contra-el-fuego/telegram-bot/flows.json` (copia rescatada, fase 0), más
> `todos-contra-el-fuego-web`: `imports/api/Subscriptions/Subscriptions.js`,
> `imports/api/ActiveFires/ActiveFires.js`, `imports/modules/url-encode.js`,
> `imports/api/Fires/server/publications.js`, y locales node-red.
>
> Objetivo: reproducir **campo a campo** los docs `notifications` que node-red
> crea, para que los workers de la fase 1a no noten el cambio de origen.
## 0. Entorno (recordatorio)
- MongoDB **3.2****no hay change streams**. La ingesta de fuegos es por
**polling** de `activefires` (marca de tiempo / checkpoint), no change stream.
- Driver `mongodb@3.7` (ya en el servicio). `subscriptions.geo` tiene índice
**2dsphere** → las geo-queries `$near`/`$geoWithin` funcionan en 3.2.
## 1. Colecciones implicadas
### `activefires` (fuego)
```
{ _id, ourid:{type:'Point',coordinates:[lon,lat]}, lat, lon, when:Date,
type:'modis'|'viirs'|'vecinal', acq_date, acq_time, confidence, frp, ...,
createdAt:Date, updatedAt:Date, fireUnion?:ObjectId }
```
La importación NASA (cron de `incinera` cada 15 min) inserta/actualiza aquí.
`createdAt` marca cuándo entró el fuego → base para el polling de fuegos nuevos.
### `subscriptions`
```
{ _id:ObjectId, location:{lat,lon}, geo:{type:'Point',coordinates:[lon,lat]},
distance:Number, // RADIO de la sub en km
owner:String, // userId
type:String, // 'web' | 'mobile' | 'telegram'
chatId?:Number, telegramBot?:String, // solo type 'telegram'
createdAt, updatedAt }
```
### `notifications` (lo que hay que generar — ver `legacy-behavior.md` §1)
Campos escritos por node-red: `userId`, `subsId`, `content`, `geo`(Point),
`type`, `when`, `sealed`, `createdAt`, `updatedAt`. Sin `notified`/`emailNotified`
(pendiente → lo consumen los workers de 1a).
## 2. Reglas del matching (por cada fuego)
1. **Candidatas (geo grueso)**: query sobre `subscriptions` con
`geo $near { $geometry: Point(fireLon,fireLat), $minDistance:0, $maxDistance:1000000 }`
(**1000 km** de prefiltro), filtrando la audiencia:
- Telegram: `{ telegramBot: <bot> }`.
- Si el flag global `notifyWebUsers` está activo, además:
`$or: [{telegramBot},{type:'web'},{type:'mobile'}]`.
- **Fase 1b (workers 1a) solo genera para `type ∈ {web, mobile}`**; el envío
Telegram es fase 1c.
2. **Filtro fino por distancia**: por cada candidata,
`dist = geolib.getDistance(sub.location, fire)` (metros, great-circle).
Si `dist/1000 <= sub.distance` (km) → **hit**.
- `km = Math.round(dist/1000 * 10) / 10` (1 decimal) para el texto.
3. **Dedupe 500 m**: antes de insertar, buscar en `notifications` del mismo
`userId` una notif con `geo $near { $maxDistance: 500 }` (0,5 km). **Si existe
alguna → NO se crea** (ya notificado en esa zona). node-red guarda además
`cacheKey = "lat,lon"` (informativo; el dedupe real es el geo-query 500 m).
4. **Contenido** (`content`), i18n por lang del **owner** de la sub:
- `type` fuego `modis`/`viirs` → clave **`kmnasa`**.
- `type` fuego `vecinal` → clave **`kmvecinal`**.
- Interpolando `{{km}}`. ⚠️ Las cadenas llevan literalmente **`км`** (cirílico,
no `km`) — es un error histórico de las traducciones; se replica verbatim.
5. **`sealed`**: `Iron.seal(firePayload, ironPassword, Iron.defaults)` con el
paquete **`iron`** (Hapi Iron). `firePayload` = el doc del fuego con
`ourversion:"1"` y **sin `_id`**; contiene al menos `{lon,lat,when,type}`. La
web lo descifra en `imports/modules/url-encode.js` (`Iron.unseal`) para la
ruta `fire/<sealed>` y la app.
- **Nota**: Iron usa IV/salt aleatorios → `sealed` **no es byte-idéntico**
entre ejecuciones ni sistemas, y **no debe compararse por bytes**. Lo que
importa es que **descifre al fuego correcto** con el mismo `ironPassword`.
El shadow compara `sealed` **descifrándolo**, no como string.
6. **Doc insertado** en `notifications`:
```
{ userId: sub.owner, subsId: sub._id, content, type: sub.type,
geo: { type:'Point', coordinates:[fire.lon, fire.lat] },
when: fire.when, sealed, createdAt: now, updatedAt: now }
```
## 3. Cadenas i18n (verbatim de los locales node-red)
`kmnasa`:
- es: `🔥 Alerta de posible fuego a {{km}} км de distancia (fuente NASA)`
- en: `🔥 Alert of possible fire in {{km}} км of distance (source NASA)`
- gl: `🔥 Alerta posible lume a {{km}} км de distancia (orixe NASA)`
- ast: `🔥 Alerta de fueu posible a {{km}} км de distancia (fonte NASA)`
- pt: `🔥 Alerta, possível fogo posto em {{km}} км de distância (origem NASA)`
`kmvecinal`:
- es: `🔥 Alerta vecinal de fuego a {{km}} км de distancia`
- gl: `🔥 Alerta veciñal de lume a {{km}} км de distancia`
- ast: `🔥 Alerta vecinal de fueu a {{km}} км de distancia`
(El sufijo `:` y el prefijo `🔥 ` los quita `trim()` en los workers — ver
`legacy-behavior.md` §5. Aquí el contenido NO lleva `:` final; node-red lo
añade solo para el mensaje de Telegram, no para el doc `notifications`.)
## 4. Ingesta de fuegos (fase 1b, sin change streams)
- **Polling** de `activefires` por `createdAt > checkpoint` (fuegos nuevos desde
la última pasada), con checkpoint persistido (colección propia del servicio,
p.ej. `matcher_state`). Alternativa/disparo: al final del import NASA.
- Re-detecciones (mismo fuego re-visto): el **dedupe 500 m** evita duplicar; no
hace falta tratar `updatedAt` para no re-notificar.
- **"Union of fires"**: `activefires` tiene `fireUnion` (agrupación de fuegos
cercanos, lógica de 2018). Pendiente de confirmar si afecta a qué fuego
dispara notificación; por defecto se procesa por fuego individual y el dedupe
500 m agrupa la vecindad. **Anotar como punto a verificar en el shadow.**
## 5. Puntos abiertos a validar en shadow
- ¿El matching viejo notifica solo fuegos `createdAt` nuevos, o re-procesa? (el
cron NASA corre cada 15 min; el subflow procesa el batch del import).
- Efecto exacto de `fireUnion` / "union of fires" en el disparo.
- `notifyWebUsers`: ¿está activo en prod? (determina si se generan notifs
web/mobile o solo telegram). Verificar el global en la config de node-red.
- Lang usado para `content`: el del **owner** de la sub (users.lang). Confirmar
con casos reales en shadow.