tcef-notifications/docs/legacy-matching.md
vjrj b3aeb6108f 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.
2026-07-13 17:58:43 +02:00

6.3 KiB

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.2no 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.