Route dossier, geocode, and Wikimedia through the backend (#351, #352, #360)

Proxy region dossier, Sentinel search, Wikipedia, and Wikidata via self-hosted
APIs; remove LocateBar client-side Nominatim fallback; migrate legacy shadow-
operator handles to operator- prefix.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
BigBodyCobain
2026-06-02 15:20:44 -06:00
parent c3dd95f6a9
commit f08781bdc9
9 changed files with 250 additions and 622 deletions
+54 -275
View File
@@ -1,11 +1,10 @@
import { useCallback, useState, useEffect } from 'react';
import type { RegionDossier, SelectedEntity } from '@/types/dashboard';
import { fetchWikipediaSummary, fetchWikidataSparql } from '@/lib/wikimediaClient';
import { API_BASE } from '@/lib/api';
// ─── CACHE ─────────────────────────────────────────────────────────────────
// Simple in-memory cache keyed by rounded lat/lng (0.1° ≈ 11km grid), 24h TTL.
const _dossierCache = new Map<string, { data: RegionDossier; ts: number }>();
const CACHE_TTL = 86400_000; // 24 hours in ms
const CACHE_TTL = 86400_000;
function getCached(lat: number, lng: number): RegionDossier | null {
const key = `${Math.round(lat * 10) / 10}_${Math.round(lng * 10) / 10}`;
@@ -18,14 +17,12 @@ function getCached(lat: number, lng: number): RegionDossier | null {
function setCache(lat: number, lng: number, data: RegionDossier) {
const key = `${Math.round(lat * 10) / 10}_${Math.round(lng * 10) / 10}`;
_dossierCache.set(key, { data, ts: Date.now() });
// Evict oldest entries if cache exceeds 500
if (_dossierCache.size > 500) {
const oldest = _dossierCache.keys().next().value;
if (oldest) _dossierCache.delete(oldest);
}
}
// ─── ESRI WORLD IMAGERY FALLBACK ───────────────────────────────────────────
function buildLocalSentinelFallback(lat: number, lng: number) {
const latSpan = 0.18;
const lngSpan = 0.24;
@@ -80,140 +77,56 @@ function buildLimitedDossier(lat: number, lng: number, error?: string): RegionDo
} as RegionDossier;
}
// ─── BROWSER-DIRECT API CALLS ──────────────────────────────────────────────
// All external APIs below support CORS — no backend proxy needed.
/** Self-hosted backend routes (#351) — no browser-direct third-party dossier calls. */
async function fetchDossierBundle(
lat: number,
lng: number,
): Promise<{ dossier: Record<string, unknown> | null; sentinel2: Record<string, unknown> }> {
const qs = `lat=${encodeURIComponent(lat)}&lng=${encodeURIComponent(lng)}`;
const [dossierRes, sentinelRes] = await Promise.allSettled([
fetch(`${API_BASE}/api/region-dossier?${qs}`),
fetch(`${API_BASE}/api/sentinel2/search?${qs}`),
]);
/** Reverse geocode via Nominatim (direct browser call). */
async function reverseGeocode(lat: number, lng: number) {
const url =
`https://nominatim.openstreetmap.org/reverse?` +
`lat=${lat}&lon=${lng}&format=json&zoom=10&addressdetails=1&accept-language=en`;
const res = await fetch(url, {
headers: { 'User-Agent': 'ShadowBroker-OSINT/1.0 (live-risk-dashboard)' },
});
if (!res.ok) throw new Error(`Nominatim HTTP ${res.status}`);
const data = await res.json();
const addr = data.address || {};
let dossier: Record<string, unknown> | null = null;
if (dossierRes.status === 'fulfilled' && dossierRes.value.ok) {
dossier = await dossierRes.value.json();
} else if (dossierRes.status === 'fulfilled') {
console.warn('[Dossier] Backend region-dossier HTTP', dossierRes.value.status);
} else {
console.warn('[Dossier] Backend region-dossier failed:', dossierRes.reason);
}
let sentinel2: Record<string, unknown> = buildLocalSentinelFallback(lat, lng);
if (sentinelRes.status === 'fulfilled' && sentinelRes.value.ok) {
sentinel2 = await sentinelRes.value.json();
} else if (sentinelRes.status === 'rejected') {
console.warn('[Dossier] Backend sentinel2/search failed:', sentinelRes.reason);
}
return { dossier, sentinel2 };
}
function dossierFromBackend(
lat: number,
lng: number,
raw: Record<string, unknown>,
sentinel2: Record<string, unknown>,
): RegionDossier {
const coords = (raw.coordinates as { lat?: number; lng?: number }) || { lat, lng };
return {
city: addr.city || addr.town || addr.village || addr.county || '',
state: addr.state || addr.region || '',
country: addr.country || '',
country_code: (addr.country_code || '').toUpperCase(),
display_name: data.display_name || '',
};
lat,
lng,
coordinates: coords,
location: raw.location ?? {},
country: raw.country ?? null,
local: raw.local ?? null,
error: raw.error as string | undefined,
warning: raw.warning as string | undefined,
sentinel2,
} as RegionDossier;
}
/** Fetch country data from RestCountries (direct browser call). */
async function fetchCountryData(countryCode: string) {
if (!countryCode) return {};
const url =
`https://restcountries.com/v3.1/alpha/${countryCode}` +
`?fields=name,population,capital,languages,region,subregion,area,currencies,borders,flag`;
const res = await fetch(url);
if (!res.ok) throw new Error(`RestCountries HTTP ${res.status}`);
const data = await res.json();
return Array.isArray(data) ? data[0] || {} : data || {};
}
/** Fetch head of state + government type from Wikidata SPARQL.
*
* Issue #218 (tg12): routes through lib/wikimediaClient so the
* Api-User-Agent header is set per Wikimedia's UA policy.
*/
async function fetchLeader(countryName: string) {
if (!countryName) return { leader: 'Unknown', government_type: 'Unknown' };
const safeName = countryName.replace(/"/g, '\\"').replace(/'/g, "\\'");
const sparql = `
SELECT ?leaderLabel ?govTypeLabel WHERE {
?country wdt:P31 wd:Q6256 ;
rdfs:label "${safeName}"@en .
OPTIONAL { ?country wdt:P35 ?leader . }
OPTIONAL { ?country wdt:P122 ?govType . }
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
} LIMIT 1
`;
const results = await fetchWikidataSparql<{
leaderLabel?: { value: string };
govTypeLabel?: { value: string };
}>(sparql);
if (results && results.length > 0) {
return {
leader: results[0].leaderLabel?.value || 'Unknown',
government_type: results[0].govTypeLabel?.value || 'Unknown',
};
}
return { leader: 'Unknown', government_type: 'Unknown' };
}
/** Fetch Wikipedia summary for a place.
*
* Issue #219 (tg12): routes through lib/wikimediaClient so the
* Api-User-Agent header is set per Wikimedia's UA policy, AND the
* shared cache means consecutive useRegionDossier + WikiImage +
* NewsFeed lookups for the same article all hit the same slot.
*/
async function fetchLocalWikiSummary(placeName: string, countryName = '') {
if (!placeName) return {};
const candidates = [placeName];
if (countryName) candidates.push(`${placeName}, ${countryName}`);
for (const name of candidates) {
const summary = await fetchWikipediaSummary(name);
if (summary) {
return {
description: summary.description,
extract: summary.extract,
thumbnail: summary.thumbnail,
};
}
}
return {};
}
/** Search for Sentinel-2 imagery via Microsoft Planetary Computer STAC (direct browser call). */
async function fetchSentinel2Direct(lat: number, lng: number) {
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const payload = {
collections: ['sentinel-2-l2a'],
intersects: { type: 'Point', coordinates: [lng, lat] },
datetime: `${thirtyDaysAgo.toISOString()}/${now.toISOString()}`,
sortby: [{ field: 'datetime', direction: 'desc' }],
limit: 3,
query: { 'eo:cloud_cover': { lt: 30 } },
};
const res = await fetch('https://planetarycomputer.microsoft.com/api/stac/v1/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Planetary Computer HTTP ${res.status}`);
const data = await res.json();
const features = data.features || [];
if (!features.length) return null; // No scenes — caller uses Esri fallback
const scenes = features.map((item: any) => {
const assets = item.assets || {};
const rendered = assets.rendered_preview || {};
const thumbnail = assets.thumbnail || {};
return {
found: true,
scene_id: item.id,
datetime: item.properties?.datetime,
cloud_cover: item.properties?.['eo:cloud_cover'],
thumbnail_url: thumbnail.href || rendered.href,
fullres_url: rendered.href || thumbnail.href,
bbox: item.bbox ? [...item.bbox] : null,
platform: item.properties?.platform || 'Sentinel-2',
};
});
return { ...scenes[0], scenes };
}
// ─── MAIN HOOK ─────────────────────────────────────────────────────────────
export function useRegionDossier(
selectedEntity: SelectedEntity | null,
setSelectedEntity: (entity: SelectedEntity | null) => void,
@@ -233,7 +146,6 @@ export function useRegionDossier(
});
setRegionDossierLoading(true);
// Check cache first
const cached = getCached(lat, lng);
if (cached) {
setRegionDossier(cached);
@@ -241,155 +153,23 @@ export function useRegionDossier(
return;
}
// Show fallback immediately while API calls are in flight
setRegionDossier({
...buildLimitedDossier(lat, lng),
sentinel2: esriFallback,
});
try {
// ── Phase 1: Geocode + Sentinel-2 in parallel ──────────────────
const [geoResult, sentinelResult] = await Promise.allSettled([
reverseGeocode(lat, lng),
fetchSentinel2Direct(lat, lng),
]);
const { dossier, sentinel2 } = await fetchDossierBundle(lat, lng);
// Parse geocode
let geo = { city: '', state: '', country: '', country_code: '', display_name: '' };
if (geoResult.status === 'fulfilled') {
geo = geoResult.value;
} else {
console.warn('[Dossier] Reverse geocode failed:', geoResult.reason);
}
// Parse sentinel
let sentinel2: Record<string, unknown> = esriFallback;
if (sentinelResult.status === 'fulfilled' && sentinelResult.value) {
sentinel2 = sentinelResult.value;
} else if (sentinelResult.status === 'rejected') {
console.warn('[Dossier] Sentinel-2 search failed:', sentinelResult.reason);
}
// sentinelResult fulfilled but null → no scenes found, keep Esri fallback
// If no country found (ocean, uninhabited), show limited dossier
if (!geo.country) {
const result: RegionDossier = {
lat,
lng,
coordinates: { lat, lng },
location: geo.display_name
? geo
: { display_name: `${lat.toFixed(4)}, ${lng.toFixed(4)}` },
country: null,
local: null,
error: 'No country data — possibly international waters or uninhabited area',
if (!dossier) {
setRegionDossier({
...buildLimitedDossier(lat, lng, 'Region dossier unavailable — check backend connection'),
sentinel2,
} as RegionDossier;
setRegionDossier(result);
setCache(lat, lng, result);
setRegionDossierLoading(false);
});
return;
}
// ── Phase 2: Country + Leader + Wiki in parallel ───────────────
const [countryResult, leaderResult, localWikiResult, countryWikiResult] =
await Promise.allSettled([
fetchCountryData(geo.country_code),
fetchLeader(geo.country),
fetchLocalWikiSummary(geo.city || geo.state, geo.country),
fetchLocalWikiSummary(geo.country, ''),
]);
// Parse country data
let countryData: Record<string, unknown> = {};
if (countryResult.status === 'fulfilled') {
countryData = countryResult.value as Record<string, unknown>;
} else {
console.warn('[Dossier] Country data failed:', countryResult.reason);
}
// Parse leader data
let leaderData = { leader: 'Unknown', government_type: 'Unknown' };
if (leaderResult.status === 'fulfilled') {
leaderData = leaderResult.value;
} else {
console.warn('[Dossier] Leader data failed:', leaderResult.reason);
}
// Parse local wiki
let localData: Record<string, string> = {};
if (localWikiResult.status === 'fulfilled') {
localData = localWikiResult.value as Record<string, string>;
} else {
console.warn('[Dossier] Local wiki failed:', localWikiResult.reason);
}
// If no local data, try country wiki summary
if (!localData.extract && countryWikiResult.status === 'fulfilled') {
const cw = countryWikiResult.value as Record<string, string>;
if (cw.extract) localData = cw;
}
// Build languages list
const languages = countryData.languages as Record<string, string> | undefined;
const langList = languages ? Object.values(languages) : [];
// Build currencies list
const currencies = countryData.currencies as
| Record<string, { name: string; symbol?: string }>
| undefined;
const currencyList: string[] = [];
if (currencies) {
for (const v of Object.values(currencies)) {
if (v && typeof v === 'object') {
const sym = v.symbol || '';
const nm = v.name || '';
currencyList.push(sym ? `${nm} (${sym})` : nm);
}
}
}
const nameData = countryData.name as
| { common?: string; official?: string }
| undefined;
const capitalData = countryData.capital as string[] | undefined;
// ── Assemble final dossier (exact same shape as backend) ───────
const result: RegionDossier = {
lat,
lng,
coordinates: { lat, lng },
location: {
city: geo.city,
state: geo.state,
country: geo.country,
country_code: geo.country_code,
display_name: geo.display_name,
},
country: {
name: nameData?.common || geo.country,
official_name: nameData?.official || '',
leader: leaderData.leader,
government_type: leaderData.government_type,
population: (countryData.population as number) || 0,
capital: capitalData?.length ? capitalData[0] : 'Unknown',
languages: langList,
currencies: currencyList,
region: (countryData.region as string) || '',
subregion: (countryData.subregion as string) || '',
area_km2: (countryData.area as number) || 0,
flag_emoji: (countryData.flag as string) || '',
},
local: {
name: geo.city,
state: geo.state,
description: localData.description || '',
summary: localData.extract || '',
thumbnail: localData.thumbnail || '',
},
sentinel2,
} as RegionDossier;
const result = dossierFromBackend(lat, lng, dossier, sentinel2);
setRegionDossier(result);
setCache(lat, lng, result);
} catch (e) {
@@ -405,7 +185,6 @@ export function useRegionDossier(
[setSelectedEntity],
);
// Clear dossier when selecting a different entity type
useEffect(() => {
if (selectedEntity?.type !== 'region_dossier') {
setRegionDossier(null);