feat(military): server-side military bases 125K + rate limiting (#496)

* feat(military): server-side military bases with 125K entries + rate limiting (#485)

Migrate military bases from 224 static client-side entries to 125,380
server-side entries stored in Redis GEO sorted sets, served via
bbox-filtered GEOSEARCH endpoint with server-side clustering.

Data pipeline:
- Pizzint/Polyglobe: 79,156 entries (Supabase extraction)
- OpenStreetMap: 45,185 entries
- MIRTA: 821 entries
- Curated strategic: 218 entries
- 277 proximity duplicates removed

Server:
- ListMilitaryBases RPC with GEOSEARCH + HMGET + tier/filter/clustering
- Antimeridian handling (split bbox queries)
- Blue-green Redis deployment with atomic version pointer switch
- geoSearchByBox() + getHashFieldsBatch() helpers in redis.ts

Security:
- @upstash/ratelimit: 60 req/min sliding window per IP
- IP spoofing fix: prioritize x-real-ip (Vercel-injected) over x-forwarded-for
- Require API key for non-browser requests (blocks unauthenticated curl/scripts)
- Input validation: allowlisted types/kinds, regex country, clamped bbox/zoom

Frontend:
- Viewport-driven loading with bbox quantization + debounce
- Server-side grid clustering at low zoom levels
- Enriched popup with kind, category badges (airforce/naval/nuclear/space)
- Static 224 bases kept as search fallback + initial render

* fix(military): fallback to production Redis keys in preview deployments

Preview deployments prefix Redis keys with `preview:{sha}:` but military
bases data is seeded to unprefixed (production) keys. When the prefixed
`military:bases:active` key is missing, fall back to the unprefixed key
and use raw (unprefixed) keys for geo/meta lookups.

* fix: remove unused 'remaining' destructure in rate-limit (TS6133)

* ci: add typecheck:api to pre-push hook to catch server-side TS errors

* debug(military): add X-Bases-Debug response header for preview diagnostics

* fix(bases): trigger initial server fetch on map load

fetchServerBases() was only called on moveend — if the user
never panned/zoomed, the API was never called and only the 224
static fallback bases showed.
This commit is contained in:
Elie Habib
2026-02-28 09:16:59 +04:00
committed by GitHub
parent 98d231595e
commit 3d2c638a72
24 changed files with 11385 additions and 38 deletions

View File

@@ -127,7 +127,7 @@ paths:
- name: icao24
in: query
description: ICAO 24-bit hex address (lowercase).
required: true
required: false
schema:
type: string
responses:
@@ -240,6 +240,78 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/military/v1/list-military-bases:
get:
tags:
- MilitaryService
summary: ListMilitaryBases
description: ListMilitaryBases retrieves military bases within a bounding box, with server-side clustering.
operationId: ListMilitaryBases
parameters:
- name: ne_lat
in: query
required: false
schema:
type: number
format: double
- name: ne_lon
in: query
required: false
schema:
type: number
format: double
- name: sw_lat
in: query
required: false
schema:
type: number
format: double
- name: sw_lon
in: query
required: false
schema:
type: number
format: double
- name: zoom
in: query
required: false
schema:
type: integer
format: int32
- name: type
in: query
required: false
schema:
type: string
- name: kind
in: query
required: false
schema:
type: string
- name: country
in: query
required: false
schema:
type: string
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ListMilitaryBasesResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Error:
@@ -873,3 +945,96 @@ components:
type: string
description: Escort vessels in the strike group.
description: USNIStrikeGroup represents a carrier strike group parsed from the article.
ListMilitaryBasesRequest:
type: object
properties:
neLat:
type: number
format: double
neLon:
type: number
format: double
swLat:
type: number
format: double
swLon:
type: number
format: double
zoom:
type: integer
format: int32
type:
type: string
kind:
type: string
country:
type: string
ListMilitaryBasesResponse:
type: object
properties:
bases:
type: array
items:
$ref: '#/components/schemas/MilitaryBaseEntry'
clusters:
type: array
items:
$ref: '#/components/schemas/MilitaryBaseCluster'
totalInView:
type: integer
format: int32
truncated:
type: boolean
MilitaryBaseEntry:
type: object
properties:
id:
type: string
name:
type: string
latitude:
type: number
format: double
longitude:
type: number
format: double
kind:
type: string
countryIso2:
type: string
type:
type: string
tier:
type: integer
format: int32
catAirforce:
type: boolean
catNaval:
type: boolean
catNuclear:
type: boolean
catSpace:
type: boolean
catTraining:
type: boolean
branch:
type: string
status:
type: string
MilitaryBaseCluster:
type: object
properties:
latitude:
type: number
format: double
longitude:
type: number
format: double
count:
type: integer
format: int32
dominantType:
type: string
expansionZoom:
type: integer
format: int32