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
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue