From de4aa8758f98d9d72d20f59abbd7ae04cfe222b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 18:27:23 +0000 Subject: [PATCH] Service: accept station ids published as strings The aggregation server serializes row ids as JSON strings, while station metadata synced from the API is keyed by number. Every production event therefore failed the station trust check as an unknown station (0 processed). Normalize station ids to numbers in the aircraft and marine parsers and in station sync, and drop events whose id is not a positive integer. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01PS44Q7WRmAzMJLhi44dzdo --- service/src/nats/parsers/aircraft.ts | 11 +++-- service/src/nats/parsers/marine.ts | 16 +++++--- service/src/nats/parsers/station-id.ts | 15 +++++++ service/src/sync/station-sync.ts | 8 ++-- service/src/types/events.ts | 20 ++++----- service/test/parsers.test.ts | 56 ++++++++++++++++++++++++++ 6 files changed, 103 insertions(+), 23 deletions(-) create mode 100644 service/src/nats/parsers/station-id.ts create mode 100644 service/test/parsers.test.ts diff --git a/service/src/nats/parsers/aircraft.ts b/service/src/nats/parsers/aircraft.ts index 6ede4a8..a7edf15 100644 --- a/service/src/nats/parsers/aircraft.ts +++ b/service/src/nats/parsers/aircraft.ts @@ -1,4 +1,5 @@ import type { AircraftMessagePayload, CoverageEvent } from '../../types/events.js'; +import { toStationId } from './station-id.js'; /** * Parse an aircraft message NATS event into a CoverageEvent. @@ -9,7 +10,8 @@ export function parseAircraftMessage(data: AircraftMessagePayload): CoverageEven const station = data.station; const message = data.message; - if (!station?.id || !station?.uuid) return null; + const stationId = toStationId(station?.id); + if (stationId === null || !station?.uuid) return null; // Try to get target position from message first, then flight let targetLat: number | null = null; @@ -29,7 +31,7 @@ export function parseAircraftMessage(data: AircraftMessagePayload): CoverageEven const hasTarget = targetLat !== null && targetLon !== null; return { - stationId: station.id, + stationId, stationUuid: station.uuid, targetLat, targetLon, @@ -55,11 +57,12 @@ export function parseAircraftFlight(data: AircraftMessagePayload): CoverageEvent const station = data.station; const flight = data.flight; - if (!station?.id || !station?.uuid) return null; + const stationId = toStationId(station?.id); + if (stationId === null || !station?.uuid) return null; if (!flight?.latitude || !flight?.longitude) return null; return { - stationId: station.id, + stationId, stationUuid: station.uuid, targetLat: flight.latitude, targetLon: flight.longitude, diff --git a/service/src/nats/parsers/marine.ts b/service/src/nats/parsers/marine.ts index b9805a1..16e8240 100644 --- a/service/src/nats/parsers/marine.ts +++ b/service/src/nats/parsers/marine.ts @@ -1,3 +1,4 @@ +import { toStationId } from './station-id.js'; import type { MarineVoyagePositionPayload, MarineAisMessagePayload, @@ -12,7 +13,8 @@ export function parseVoyagePosition(data: MarineVoyagePositionPayload): Coverage const station = data.station; const pos = data.voyagePosition; - if (!station?.id || !station?.uuid) return null; + const stationId = toStationId(station?.id); + if (stationId === null || !station?.uuid) return null; let targetLat: number | null = null; let targetLon: number | null = null; @@ -28,7 +30,7 @@ export function parseVoyagePosition(data: MarineVoyagePositionPayload): Coverage const hasTarget = targetLat !== null && targetLon !== null; return { - stationId: station.id, + stationId, stationUuid: station.uuid, targetLat, targetLon, @@ -53,14 +55,15 @@ export function parseVoyage(data: MarineVoyagePositionPayload): CoverageEvent | const station = data.station; const voyage = data.voyage; - if (!station?.id || !station?.uuid) return null; + const stationId = toStationId(station?.id); + if (stationId === null || !station?.uuid) return null; const targetLat = voyage?.last_latitude ?? null; const targetLon = voyage?.last_longitude ?? null; const hasTarget = targetLat !== null && targetLon !== null; return { - stationId: station.id, + stationId, stationUuid: station.uuid, targetLat, targetLon, @@ -85,10 +88,11 @@ export function parseVoyage(data: MarineVoyagePositionPayload): CoverageEvent | export function parseAisMessage(data: MarineAisMessagePayload): CoverageEvent | null { const station = data.station; - if (!station?.id || !station?.uuid) return null; + const stationId = toStationId(station?.id); + if (stationId === null || !station?.uuid) return null; return { - stationId: station.id, + stationId, stationUuid: station.uuid, targetLat: null, targetLon: null, diff --git a/service/src/nats/parsers/station-id.ts b/service/src/nats/parsers/station-id.ts new file mode 100644 index 0000000..a8d59b1 --- /dev/null +++ b/service/src/nats/parsers/station-id.ts @@ -0,0 +1,15 @@ +/** + * The aggregation server publishes row ids as JSON strings ("1234"), while the + * main API returns them as numbers. Station metadata is keyed by number, so + * normalize here or every event looks like it came from an unknown station. + */ +export function toStationId(value: unknown): number | null { + if (typeof value === 'number') { + return Number.isSafeInteger(value) && value > 0 ? value : null; + } + if (typeof value === 'string' && /^\d+$/.test(value)) { + const id = Number(value); + return Number.isSafeInteger(id) && id > 0 ? id : null; + } + return null; +} diff --git a/service/src/sync/station-sync.ts b/service/src/sync/station-sync.ts index 8f06bf8..dcbdf87 100644 --- a/service/src/sync/station-sync.ts +++ b/service/src/sync/station-sync.ts @@ -1,5 +1,6 @@ import { config } from '../config.js'; import type { StationMeta } from '../types/events.js'; +import { toStationId } from '../nats/parsers/station-id.js'; /** * Periodically syncs station metadata from the main API. @@ -84,7 +85,8 @@ export class StationSync { let enriched = 0; for (const s of stations) { - if (!s.id) continue; + const id = toStationId(s.id); + if (id === null) continue; let lat = parseFloat(s.latitude) || parseFloat(s.fuzzedLatitude) || 0; let lon = parseFloat(s.longitude) || parseFloat(s.fuzzedLongitude) || 0; @@ -99,8 +101,8 @@ export class StationSync { } } - this.stationMap.set(s.id, { - id: s.id, + this.stationMap.set(id, { + id, uuid: s.uuid ?? '', ident: s.ident ?? '', latitude: lat, diff --git a/service/src/types/events.ts b/service/src/types/events.ts index 2cb42d4..1f22f1b 100644 --- a/service/src/types/events.ts +++ b/service/src/types/events.ts @@ -52,7 +52,7 @@ export interface AircraftMessagePayload { transmissionType?: string; }; station?: { - id?: number; + id?: number | string; uuid?: string; ident?: string; latitude?: number; @@ -63,7 +63,7 @@ export interface AircraftMessagePayload { source_type?: string; }; message?: { - id?: number; + id?: number | string; latitude?: number; longitude?: number; altitude?: number; @@ -76,18 +76,18 @@ export interface AircraftMessagePayload { source_type?: string; }; airframe?: { - id?: number; + id?: number | string; tail?: string; }; flight?: { - id?: number; + id?: number | string; latitude?: number; longitude?: number; altitude?: number; track?: number; }; messageReport?: { - id?: number; + id?: number | string; }; } @@ -99,7 +99,7 @@ export interface MarineVoyagePositionPayload { transmissionType?: string; }; station?: { - id?: number; + id?: number | string; uuid?: string; latitude?: number; longitude?: number; @@ -114,12 +114,12 @@ export interface MarineVoyagePositionPayload { position_accuracy?: boolean; }; voyage?: { - id?: number; + id?: number | string; last_latitude?: number; last_longitude?: number; }; vessel?: { - id?: number; + id?: number | string; mmsi?: string; name?: string; }; @@ -132,13 +132,13 @@ export interface MarineAisMessagePayload { application?: string; }; station?: { - id?: number; + id?: number | string; uuid?: string; latitude?: number; longitude?: number; source_type?: string; }; aisMessage?: { - id?: number; + id?: number | string; }; } diff --git a/service/test/parsers.test.ts b/service/test/parsers.test.ts new file mode 100644 index 0000000..d5ab36f --- /dev/null +++ b/service/test/parsers.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { CoverageStore } from '../src/aggregation/coverage-store.js'; +import { parseAircraftMessage, parseAircraftFlight } from '../src/nats/parsers/aircraft.js'; +import { toStationId } from '../src/nats/parsers/station-id.js'; +import type { StationMeta } from '../src/types/events.js'; + +describe('toStationId', () => { + it('accepts numbers and digit strings', () => { + expect(toStationId(1234)).toBe(1234); + expect(toStationId('1234')).toBe(1234); + }); + + it('rejects anything that is not a positive integer id', () => { + for (const bad of [0, -1, 1.5, '', '0', '12a', ' 12', '1e3', null, undefined, {}, NaN]) { + expect(toStationId(bad)).toBeNull(); + } + }); +}); + +describe('aircraft parsers with production payloads', () => { + // The aggregation server serializes ids as strings. + const payload = { + source: { name: 'dumpvdl2' }, + station: { id: '1234', uuid: 'uuid-1234', ident: 'TEST-1234' }, + message: { id: '987654321', latitude: 48.5, longitude: 11.2, altitude: 36000, timestamp: new Date().toISOString() }, + flight: { id: '555', latitude: 48.6, longitude: 11.3, altitude: 36000 }, + }; + + it('normalizes a string station id to a number', () => { + expect(parseAircraftMessage(payload)?.stationId).toBe(1234); + expect(parseAircraftFlight(payload)?.stationId).toBe(1234); + }); + + it('drops events with no usable station id', () => { + expect(parseAircraftMessage({ ...payload, station: { ...payload.station, id: 'abc' } })).toBeNull(); + expect(parseAircraftMessage({ ...payload, station: { uuid: 'uuid-1234' } })).toBeNull(); + }); + + describe('ingest', () => { + let store: CoverageStore; + afterEach(() => store.destroy()); + + it('accepts an event whose station id arrived as a string', () => { + const meta: StationMeta = { + id: 1234, uuid: 'uuid-1234', ident: 'TEST-1234', latitude: 48.0, longitude: 11.0, + sourceType: 'vdl', status: 'active', flagged: false, blocked: false, trustScore: 0.5, + lastSyncedAt: new Date(), + }; + store = new CoverageStore(); + store.stationMap.set(1234, meta); + store.ingest(parseAircraftMessage(payload)!); + expect(store.eventsProcessed).toBe(1); + expect(store.eventsRejected).toBe(0); + }); + }); +});