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

39
src/matcher/content.ts Normal file
View 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
View 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
View 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
View 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
View 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;
}