diff --git a/docs/legacy-matching.md b/docs/legacy-matching.md new file mode 100644 index 0000000..9633bd0 --- /dev/null +++ b/docs/legacy-matching.md @@ -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: }`. + - 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/` 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. diff --git a/package-lock.json b/package-lock.json index 269c96c..d6dd725 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8dfdf21..b4d4701 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/matcher/content.ts b/src/matcher/content.ts new file mode 100644 index 0000000..5f3e72e --- /dev/null +++ b/src/matcher/content.ts @@ -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>> = { + 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)); +} diff --git a/src/matcher/geo.ts b/src/matcher/geo.ts new file mode 100644 index 0000000..12682d8 --- /dev/null +++ b/src/matcher/geo.ts @@ -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): 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; +} diff --git a/src/matcher/matcher.ts b/src/matcher/matcher.ts new file mode 100644 index 0000000..36008e1 --- /dev/null +++ b/src/matcher/matcher.ts @@ -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; + /** Dedupe: is there already a notification for this user within `meters` of the fire? */ + existsNotifNear(userId: string, lon: number, lat: number, meters: number): Promise; + /** Owner language (users.lang) for the content string. */ + userLang(userId: string): Promise; + ironPassword: string; + /** Only these subscription types produce notifications here (workers 1a consume web/mobile). */ + audienceTypes: Set; + 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 { + const subs = await ctx.candidateSubs(fire); + const now = ctx.now(); + const out: GeneratedNotif[] = []; + const seenUsers = new Set(); + + 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; +} diff --git a/src/matcher/seal.ts b/src/matcher/seal.ts new file mode 100644 index 0000000..a1c2f26 --- /dev/null +++ b/src/matcher/seal.ts @@ -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 { + 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/` route. + */ +export async function sealFire(fire: FireDoc, ironPassword: string): Promise { + return Iron.seal(fireSealPayload(fire), ironPassword, Iron.defaults); +} diff --git a/src/matcher/types.ts b/src/matcher/types.ts new file mode 100644 index 0000000..b0815f3 --- /dev/null +++ b/src/matcher/types.ts @@ -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; +} diff --git a/test/matcher.test.ts b/test/matcher.test.ts new file mode 100644 index 0000000..6d45319 --- /dev/null +++ b/test/matcher.test.ts @@ -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 { + 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 { + 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 { + 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'); + }); +});