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:
parent
4f8d866226
commit
b3aeb6108f
9 changed files with 507 additions and 0 deletions
122
docs/legacy-matching.md
Normal file
122
docs/legacy-matching.md
Normal 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.
|
||||
63
package-lock.json
generated
63
package-lock.json
generated
|
|
@ -8,8 +8,10 @@
|
|||
"name": "tcef-notifications",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@hapi/iron": "^7.0.1",
|
||||
"bullmq": "^5.34.0",
|
||||
"firebase-admin": "^13.0.2",
|
||||
"geolib": "^3.3.14",
|
||||
"handlebars": "^4.7.8",
|
||||
"ioredis": "^5.4.2",
|
||||
"juice": "^11.0.0",
|
||||
|
|
@ -704,6 +706,61 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/b64": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-6.0.1.tgz",
|
||||
"integrity": "sha512-ZvjX4JQReUmBheeCq+S9YavcnMMHWqx3S0jHNXWIM1kQDxB9cyfSycpVvjfrKcIS8Mh5N3hmu/YKo4Iag9g2Kw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/boom": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz",
|
||||
"integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/bourne": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz",
|
||||
"integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/cryptiles": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/cryptiles/-/cryptiles-6.0.3.tgz",
|
||||
"integrity": "sha512-r6VKalpbMHz4ci3gFjFysBmhwCg70RpYZy6OkjEpdXzAYnYFX5XsW7n4YMJvuIYpnMwLxGUjK/cBhA7X3JDvXw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/boom": "^10.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/hoek": {
|
||||
"version": "11.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz",
|
||||
"integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/iron": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/iron/-/iron-7.0.1.tgz",
|
||||
"integrity": "sha512-tEZnrOujKpS6jLKliyWBl3A9PaE+ppuL/+gkbyPPDb/l2KSKQyH4lhMkVb+sBhwN+qaxxlig01JRqB8dk/mPxQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/b64": "^6.0.1",
|
||||
"@hapi/boom": "^10.0.1",
|
||||
"@hapi/bourne": "^3.0.0",
|
||||
"@hapi/cryptiles": "^6.0.1",
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
|
||||
|
|
@ -2573,6 +2630,12 @@
|
|||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/geolib": {
|
||||
"version": "3.3.14",
|
||||
"resolved": "https://registry.npmjs.org/geolib/-/geolib-3.3.14.tgz",
|
||||
"integrity": "sha512-uQ1772h3OjhWvL/HhSRZTMjBKIKoc4wFksLDqzqOkuG/2TgBbTwFamU0Disx3sNFk/BOweHyhKoVaYDuLIpsxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@
|
|||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/iron": "^7.0.1",
|
||||
"bullmq": "^5.34.0",
|
||||
"firebase-admin": "^13.0.2",
|
||||
"geolib": "^3.3.14",
|
||||
"handlebars": "^4.7.8",
|
||||
"ioredis": "^5.4.2",
|
||||
"juice": "^11.0.0",
|
||||
|
|
|
|||
39
src/matcher/content.ts
Normal file
39
src/matcher/content.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { Lang } from '../messages.js';
|
||||
|
||||
/**
|
||||
* i18n strings for the alert content, verbatim from the node-red locales
|
||||
* (docs/legacy-matching.md §3). NOTE the literal Cyrillic "км" — a historic
|
||||
* typo in the translations, reproduced exactly so output matches the old system.
|
||||
*/
|
||||
export const KM_I18N: Record<'kmnasa' | 'kmvecinal', Partial<Record<Lang, string>>> = {
|
||||
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)',
|
||||
},
|
||||
kmvecinal: {
|
||||
es: '🔥 Alerta vecinal de fuego a {{km}} км de distancia',
|
||||
gl: '🔥 Alerta veciñal de lume a {{km}} км de distancia',
|
||||
},
|
||||
};
|
||||
|
||||
/** Which i18n key a fire type maps to (modis/viirs → NASA, vecinal → neighbour). */
|
||||
export function contentKey(fireType: string): 'kmnasa' | 'kmvecinal' {
|
||||
return fireType === 'vecinal' ? 'kmvecinal' : 'kmnasa';
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance in km rounded to 1 decimal, exactly as the old node-red node:
|
||||
* `Math.round(dist/1000 * 10) / 10`.
|
||||
*/
|
||||
export function kmRounded(distanceMeters: number): number {
|
||||
return Math.round((distanceMeters / 1000) * 10) / 10;
|
||||
}
|
||||
|
||||
/** Build the notification `content` string for a fire+subscriber, per old rules. */
|
||||
export function buildMatchContent(fireType: string, lang: Lang, km: number): string {
|
||||
const key = contentKey(fireType);
|
||||
const table = KM_I18N[key];
|
||||
const template = table[lang] ?? table.es ?? key;
|
||||
return template.replaceAll('{{km}}', String(km));
|
||||
}
|
||||
19
src/matcher/geo.ts
Normal file
19
src/matcher/geo.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { getDistance } from 'geolib';
|
||||
import type { FireDoc, SubscriptionDoc } from './types.js';
|
||||
|
||||
/**
|
||||
* Great-circle distance in metres between a subscription and a fire, using the
|
||||
* SAME library (geolib.getDistance) the old node-red flow used, so distances —
|
||||
* and therefore the `dist/1000 <= sub.distance` boundary — are identical.
|
||||
*/
|
||||
export function distanceMeters(sub: SubscriptionDoc, fire: Pick<FireDoc, 'lat' | 'lon'>): number {
|
||||
return getDistance(
|
||||
{ latitude: sub.location.lat, longitude: sub.location.lon },
|
||||
{ latitude: fire.lat, longitude: fire.lon },
|
||||
);
|
||||
}
|
||||
|
||||
/** A subscription is hit when the fire is within its radius: dist/1000 <= sub.distance (km). */
|
||||
export function isHit(sub: SubscriptionDoc, distMeters: number): boolean {
|
||||
return distMeters / 1000 <= sub.distance;
|
||||
}
|
||||
84
src/matcher/matcher.ts
Normal file
84
src/matcher/matcher.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import type { ObjectId } from 'mongodb';
|
||||
import { normalizeLang } from '../messages.js';
|
||||
import { buildMatchContent, kmRounded } from './content.js';
|
||||
import { distanceMeters, isHit } from './geo.js';
|
||||
import { sealFire } from './seal.js';
|
||||
import type { FireDoc, SubscriptionDoc } from './types.js';
|
||||
|
||||
/** A `notifications` doc generated by the matcher (same shape node-red writes). */
|
||||
export interface GeneratedNotif {
|
||||
userId: string;
|
||||
subsId: ObjectId;
|
||||
content: string;
|
||||
type: string; // sub.type: 'web' | 'mobile'
|
||||
geo: { type: 'Point'; coordinates: [number, number] }; // [fire.lon, fire.lat]
|
||||
when: Date;
|
||||
sealed: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface MatchContext {
|
||||
/** Candidate subs: geo $near fire within 1000km, already filtered to audienceTypes. */
|
||||
candidateSubs(fire: FireDoc): Promise<SubscriptionDoc[]>;
|
||||
/** Dedupe: is there already a notification for this user within `meters` of the fire? */
|
||||
existsNotifNear(userId: string, lon: number, lat: number, meters: number): Promise<boolean>;
|
||||
/** Owner language (users.lang) for the content string. */
|
||||
userLang(userId: string): Promise<string | undefined>;
|
||||
ironPassword: string;
|
||||
/** Only these subscription types produce notifications here (workers 1a consume web/mobile). */
|
||||
audienceTypes: Set<string>;
|
||||
now(): Date;
|
||||
}
|
||||
|
||||
/** The 500 m dedupe radius from the old flow. */
|
||||
export const DEDUPE_METERS = 500;
|
||||
|
||||
/**
|
||||
* Match one fire against subscriptions and return the notification docs to
|
||||
* create — faithfully reproducing the node-red matching (docs/legacy-matching.md).
|
||||
*
|
||||
* Dedupe is twofold: (a) against existing notifications within 500 m for the
|
||||
* user (`existsNotifNear`), and (b) intra-batch — at most ONE notification per
|
||||
* user per fire, since every notif for a fire shares the fire's coordinates and
|
||||
* would fall inside the 500 m radius of the first.
|
||||
*/
|
||||
export async function matchFire(fire: FireDoc, ctx: MatchContext): Promise<GeneratedNotif[]> {
|
||||
const subs = await ctx.candidateSubs(fire);
|
||||
const now = ctx.now();
|
||||
const out: GeneratedNotif[] = [];
|
||||
const seenUsers = new Set<string>();
|
||||
|
||||
for (const sub of subs) {
|
||||
if (!ctx.audienceTypes.has(sub.type)) continue;
|
||||
if (seenUsers.has(sub.owner)) continue; // one per user per fire
|
||||
|
||||
const dist = distanceMeters(sub, fire);
|
||||
if (!isHit(sub, dist)) continue;
|
||||
|
||||
// Dedupe against already-stored notifications within 500 m for this user.
|
||||
if (await ctx.existsNotifNear(sub.owner, fire.lon, fire.lat, DEDUPE_METERS)) {
|
||||
seenUsers.add(sub.owner);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lang = normalizeLang(await ctx.userLang(sub.owner));
|
||||
const content = buildMatchContent(fire.type, lang, kmRounded(dist));
|
||||
const sealed = await sealFire(fire, ctx.ironPassword);
|
||||
|
||||
out.push({
|
||||
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,
|
||||
});
|
||||
seenUsers.add(sub.owner);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
23
src/matcher/seal.ts
Normal file
23
src/matcher/seal.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import Iron from '@hapi/iron';
|
||||
import type { FireDoc } from './types.js';
|
||||
|
||||
/**
|
||||
* Build the object that gets sealed into `notification.sealed`, mirroring the
|
||||
* old node-red node: the fire payload WITHOUT `_id`, plus `ourversion: "1"`.
|
||||
* The web only reads {lon, lat, when, type} on unseal (findFire), but we seal
|
||||
* the whole fire (minus _id) to stay faithful.
|
||||
*/
|
||||
export function fireSealPayload(fire: FireDoc): Record<string, unknown> {
|
||||
const { _id, ...rest } = fire;
|
||||
return { ...rest, ourversion: '1' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal with Hapi Iron (Fe26.2), matching the web's `iron@5` + `Iron.defaults`
|
||||
* and the same `ironPassword`. Iron uses random salt/IV, so the output is NOT
|
||||
* byte-stable — it is meant to be decrypted, not compared. The web unseals it
|
||||
* for the `fire/<sealed>` route.
|
||||
*/
|
||||
export async function sealFire(fire: FireDoc, ironPassword: string): Promise<string> {
|
||||
return Iron.seal(fireSealPayload(fire), ironPassword, Iron.defaults);
|
||||
}
|
||||
27
src/matcher/types.ts
Normal file
27
src/matcher/types.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { ObjectId } from 'mongodb';
|
||||
|
||||
/** A document in `activefires` (see docs/legacy-matching.md §1). */
|
||||
export interface FireDoc {
|
||||
_id: ObjectId;
|
||||
ourid?: { type: 'Point'; coordinates: [number, number] }; // [lon, lat]
|
||||
lat: number;
|
||||
lon: number;
|
||||
when: Date;
|
||||
type: 'modis' | 'viirs' | 'vecinal' | string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
fireUnion?: ObjectId;
|
||||
[k: string]: unknown; // other NASA fields (acq_date, frp, confidence, ...)
|
||||
}
|
||||
|
||||
/** A document in `subscriptions`. */
|
||||
export interface SubscriptionDoc {
|
||||
_id: ObjectId;
|
||||
location: { lat: number; lon: number };
|
||||
geo: { type: 'Point'; coordinates: [number, number] }; // [lon, lat]
|
||||
distance: number; // radius in km
|
||||
owner: string; // userId
|
||||
type: 'web' | 'mobile' | 'telegram' | string;
|
||||
chatId?: number;
|
||||
telegramBot?: string;
|
||||
}
|
||||
128
test/matcher.test.ts
Normal file
128
test/matcher.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import { buildMatchContent, contentKey, kmRounded } from '../src/matcher/content.js';
|
||||
import { distanceMeters, isHit } from '../src/matcher/geo.js';
|
||||
import { matchFire, type MatchContext } from '../src/matcher/matcher.js';
|
||||
import type { FireDoc, SubscriptionDoc } from '../src/matcher/types.js';
|
||||
|
||||
function fire(over: Partial<FireDoc> = {}): FireDoc {
|
||||
return {
|
||||
_id: new ObjectId(),
|
||||
lat: 40.4168,
|
||||
lon: -3.7038,
|
||||
when: new Date('2026-07-13T12:00:00Z'),
|
||||
type: 'viirs',
|
||||
createdAt: new Date('2026-07-13T12:05:00Z'),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function sub(over: Partial<SubscriptionDoc> = {}): SubscriptionDoc {
|
||||
const lat = over.location?.lat ?? 40.42;
|
||||
const lon = over.location?.lon ?? -3.70;
|
||||
return {
|
||||
_id: new ObjectId(),
|
||||
location: { lat, lon },
|
||||
geo: { type: 'Point', coordinates: [lon, lat] },
|
||||
distance: 40,
|
||||
owner: 'u1',
|
||||
type: 'mobile',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('content', () => {
|
||||
it('maps fire type to i18n key', () => {
|
||||
expect(contentKey('viirs')).toBe('kmnasa');
|
||||
expect(contentKey('modis')).toBe('kmnasa');
|
||||
expect(contentKey('vecinal')).toBe('kmvecinal');
|
||||
});
|
||||
it('rounds km to 1 decimal like the old node', () => {
|
||||
expect(kmRounded(64543)).toBe(64.5);
|
||||
expect(kmRounded(500)).toBe(0.5);
|
||||
});
|
||||
it('builds content verbatim incl. the Cyrillic км', () => {
|
||||
expect(buildMatchContent('viirs', 'es', 12.3)).toBe('🔥 Alerta de posible fuego a 12.3 км de distancia (fuente NASA)');
|
||||
expect(buildMatchContent('viirs', 'en', 5)).toBe('🔥 Alert of possible fire in 5 км of distance (source NASA)');
|
||||
expect(buildMatchContent('vecinal', 'es', 1.1)).toBe('🔥 Alerta vecinal de fuego a 1.1 км de distancia');
|
||||
});
|
||||
it('falls back to es for unknown langs', () => {
|
||||
expect(buildMatchContent('viirs', 'gl', 2)).toContain('lume');
|
||||
});
|
||||
});
|
||||
|
||||
describe('geo', () => {
|
||||
it('computes distance and hit boundary', () => {
|
||||
const s = sub({ location: { lat: 40.4168, lon: -3.7038 }, distance: 1 });
|
||||
const f = fire({ lat: 40.4168, lon: -3.7038 });
|
||||
expect(distanceMeters(s, f)).toBe(0);
|
||||
expect(isHit(s, 0)).toBe(true);
|
||||
});
|
||||
it('respects the radius: just outside is not a hit', () => {
|
||||
const s = sub({ distance: 10 });
|
||||
expect(isHit(s, 10_000)).toBe(true); // exactly 10km
|
||||
expect(isHit(s, 10_001)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function ctx(over: Partial<MatchContext> = {}): MatchContext {
|
||||
return {
|
||||
candidateSubs: async () => [],
|
||||
existsNotifNear: async () => false,
|
||||
userLang: async () => 'es',
|
||||
ironPassword: 'x'.repeat(32),
|
||||
audienceTypes: new Set(['web', 'mobile']),
|
||||
now: () => new Date('2026-07-13T12:06:00Z'),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('matchFire', () => {
|
||||
it('generates a notification doc identical in shape to node-red', async () => {
|
||||
const f = fire();
|
||||
const s = sub({ owner: 'u1', type: 'mobile', distance: 40 });
|
||||
const notifs = await matchFire(f, ctx({ candidateSubs: async () => [s] }));
|
||||
expect(notifs).toHaveLength(1);
|
||||
const n = notifs[0]!;
|
||||
expect(n.userId).toBe('u1');
|
||||
expect(n.subsId).toBe(s._id);
|
||||
expect(n.type).toBe('mobile');
|
||||
expect(n.geo).toEqual({ type: 'Point', coordinates: [f.lon, f.lat] });
|
||||
expect(n.when).toBe(f.when);
|
||||
expect(n.content).toContain('км');
|
||||
expect(typeof n.sealed).toBe('string');
|
||||
expect(n.sealed.startsWith('Fe26.2')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips subs outside their radius', async () => {
|
||||
const far = sub({ location: { lat: 41.5, lon: -3.7 }, distance: 1 }); // ~120km away, radius 1km
|
||||
const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [far] }));
|
||||
expect(notifs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('dedupes against existing notifications within 500m', async () => {
|
||||
const s = sub();
|
||||
const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [s], existsNotifNear: async () => true }));
|
||||
expect(notifs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('emits at most one notification per user per fire', async () => {
|
||||
const s1 = sub({ _id: new ObjectId(), owner: 'u1', distance: 50 });
|
||||
const s2 = sub({ _id: new ObjectId(), owner: 'u1', distance: 50 });
|
||||
const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [s1, s2] }));
|
||||
expect(notifs).toHaveLength(1);
|
||||
expect(notifs[0]!.subsId).toBe(s1._id); // first hit wins
|
||||
});
|
||||
|
||||
it('ignores subscription types outside the audience (e.g. telegram)', async () => {
|
||||
const tg = sub({ type: 'telegram', chatId: 123 });
|
||||
const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [tg] }));
|
||||
expect(notifs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses the owner language for content', async () => {
|
||||
const s = sub({ owner: 'uEN' });
|
||||
const notifs = await matchFire(fire(), ctx({ candidateSubs: async () => [s], userLang: async () => 'en' }));
|
||||
expect(notifs[0]!.content).toContain('source NASA');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue