diff --git a/api/bootstrap.js b/api/bootstrap.js index c82a1008a9..0ab6aaa3ea 100644 --- a/api/bootstrap.js +++ b/api/bootstrap.js @@ -26,7 +26,6 @@ const BOOTSTRAP_CACHE_KEYS = { minerals: 'supply_chain:minerals:v2', giving: 'giving:summary:v1', climateAnomalies: 'climate:anomalies:v1', - climateDisasters: 'climate:disasters:v1', radiationWatch: 'radiation:observations:v1', thermalEscalation: 'thermal:escalation:v1', crossSourceSignals: 'intelligence:cross-source-signals:v1', @@ -87,7 +86,6 @@ const BOOTSTRAP_CACHE_KEYS = { const SLOW_KEYS = new Set([ 'bisPolicy', 'bisExchange', 'bisCredit', 'minerals', 'giving', 'sectors', 'etfFlows', 'wildfires', 'climateAnomalies', - 'climateDisasters', 'radiationWatch', 'thermalEscalation', 'crossSourceSignals', 'cyberThreats', 'techReadiness', 'progressData', 'renewableEnergy', 'naturalEvents', diff --git a/api/health.js b/api/health.js index e1eecdcbc4..b37f13177a 100644 --- a/api/health.js +++ b/api/health.js @@ -10,7 +10,6 @@ const BOOTSTRAP_KEYS = { sectors: 'market:sectors:v1', etfFlows: 'market:etf-flows:v1', climateAnomalies: 'climate:anomalies:v1', - climateDisasters: 'climate:disasters:v1', wildfires: 'wildfire:fires:v1', marketQuotes: 'market:stocks-bootstrap:v1', commodityQuotes: 'market:commodities-bootstrap:v1', @@ -128,7 +127,6 @@ const SEED_META = { wildfires: { key: 'seed-meta:wildfire:fires', maxStaleMin: 360 }, // FIRMS NRT resets at midnight UTC; new-day data takes 3-6h to accumulate outages: { key: 'seed-meta:infra:outages', maxStaleMin: 30 }, climateAnomalies: { key: 'seed-meta:climate:anomalies', maxStaleMin: 120 }, // runs as independent Railway cron (0 */2 * * *) - climateDisasters: { key: 'seed-meta:climate:disasters', maxStaleMin: 720 }, // runs every 6h; 720min = 2x interval unrestEvents: { key: 'seed-meta:unrest:events', maxStaleMin: 120 }, // 45min cron; 120 = 2h grace (was 75 = 30min buffer, too tight) cyberThreats: { key: 'seed-meta:cyber:threats', maxStaleMin: 240 }, // 2h interval; 240min = 2x interval cryptoQuotes: { key: 'seed-meta:market:crypto', maxStaleMin: 30 }, diff --git a/api/mcp.ts b/api/mcp.ts index de689c5e6a..d5789b8126 100644 --- a/api/mcp.ts +++ b/api/mcp.ts @@ -180,9 +180,9 @@ const TOOL_REGISTRY: ToolDef[] = [ }, { name: 'get_climate_data', - description: 'Climate anomalies, climate-relevant disaster alerts (GDACS, ReliefWeb, NASA FIRMS), weather alerts, and natural environmental events.', + description: 'Climate anomalies (Open-Meteo temperature/precipitation deviations), weather alerts, and natural environmental events from NASA EONET.', inputSchema: { type: 'object', properties: {}, required: [] }, - _cacheKeys: ['climate:anomalies:v1', 'climate:disasters:v1', 'weather:alerts:v1'], + _cacheKeys: ['climate:anomalies:v1', 'weather:alerts:v1'], _seedMetaKey: 'seed-meta:climate:anomalies', _maxStaleMin: 120, }, diff --git a/api/seed-health.js b/api/seed-health.js index 8e81e17e91..7fd6ea59b4 100644 --- a/api/seed-health.js +++ b/api/seed-health.js @@ -12,7 +12,6 @@ const SEED_DOMAINS = { 'wildfire:fires': { key: 'seed-meta:wildfire:fires', intervalMin: 60 }, 'infra:outages': { key: 'seed-meta:infra:outages', intervalMin: 15 }, 'climate:anomalies': { key: 'seed-meta:climate:anomalies', intervalMin: 60 }, - 'climate:disasters': { key: 'seed-meta:climate:disasters', intervalMin: 360 }, // Phase 2 — Parameterized endpoints 'unrest:events': { key: 'seed-meta:unrest:events', intervalMin: 15 }, 'cyber:threats': { key: 'seed-meta:cyber:threats', intervalMin: 240 }, diff --git a/docs/api/ClimateService.openapi.json b/docs/api/ClimateService.openapi.json index a08035eb92..281d57c8e1 100644 --- a/docs/api/ClimateService.openapi.json +++ b/docs/api/ClimateService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"ClimateAnomaly":{"description":"ClimateAnomaly represents a temperature or precipitation deviation from historical norms.\n Sourced from Open-Meteo / ERA5 reanalysis data.","properties":{"location":{"$ref":"#/components/schemas/GeoCoordinates"},"period":{"description":"Time period covered (e.g., \"2024-W03\", \"2024-01\").","minLength":1,"type":"string"},"precipDelta":{"description":"Precipitation deviation from normal as a percentage.","format":"double","type":"number"},"severity":{"description":"AnomalySeverity represents the severity of a climate anomaly.\n Maps to existing TS union: 'normal' | 'moderate' | 'extreme'.","enum":["ANOMALY_SEVERITY_UNSPECIFIED","ANOMALY_SEVERITY_NORMAL","ANOMALY_SEVERITY_MODERATE","ANOMALY_SEVERITY_EXTREME"],"type":"string"},"tempDelta":{"description":"Temperature deviation from normal in degrees Celsius.","format":"double","type":"number"},"type":{"description":"AnomalyType represents the type of climate anomaly.\n Maps to existing TS union: 'warm' | 'cold' | 'wet' | 'dry' | 'mixed'.","enum":["ANOMALY_TYPE_UNSPECIFIED","ANOMALY_TYPE_WARM","ANOMALY_TYPE_COLD","ANOMALY_TYPE_WET","ANOMALY_TYPE_DRY","ANOMALY_TYPE_MIXED"],"type":"string"},"zone":{"description":"Climate zone name (e.g., \"Northern Europe\", \"Sahel\").","minLength":1,"type":"string"}},"required":["zone","period"],"type":"object"},"ClimateDisaster":{"description":"ClimateDisaster represents a climate-relevant disaster event from seeded caches.","properties":{"affectedPopulation":{"description":"Affected population when available.","format":"int32","type":"integer"},"country":{"description":"Country name.","type":"string"},"countryCode":{"description":"ISO 3166-1 alpha-2 country code.","type":"string"},"id":{"description":"Unique event identifier.","type":"string"},"lat":{"description":"Event latitude.","format":"double","type":"number"},"lng":{"description":"Event longitude.","format":"double","type":"number"},"name":{"description":"Human-readable event name.","type":"string"},"severity":{"description":"Severity level. GDACS: green/orange/red. ReliefWeb: low/medium/high.","type":"string"},"source":{"description":"Source system: GDACS, ReliefWeb, NASA FIRMS.","type":"string"},"sourceUrl":{"description":"Source URL for drill-down.","type":"string"},"startedAt":{"description":"Event start time as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"status":{"description":"Event status: alert/ongoing/past.","type":"string"},"type":{"description":"Disaster type: flood, cyclone, drought, heatwave, wildfire.","type":"string"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"GeoCoordinates":{"description":"GeoCoordinates represents a geographic location using WGS84 coordinates.","properties":{"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","format":"double","maximum":90,"minimum":-90,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","format":"double","maximum":180,"minimum":-180,"type":"number"}},"type":"object"},"ListClimateAnomaliesRequest":{"description":"ListClimateAnomaliesRequest specifies filters for retrieving climate anomaly data.","properties":{"cursor":{"description":"Cursor for next page.","type":"string"},"minSeverity":{"description":"AnomalySeverity represents the severity of a climate anomaly.\n Maps to existing TS union: 'normal' | 'moderate' | 'extreme'.","enum":["ANOMALY_SEVERITY_UNSPECIFIED","ANOMALY_SEVERITY_NORMAL","ANOMALY_SEVERITY_MODERATE","ANOMALY_SEVERITY_EXTREME"],"type":"string"},"pageSize":{"description":"Maximum items per page (1-100).","format":"int32","type":"integer"}},"type":"object"},"ListClimateAnomaliesResponse":{"description":"ListClimateAnomaliesResponse contains the list of climate anomalies.","properties":{"anomalies":{"items":{"$ref":"#/components/schemas/ClimateAnomaly"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PaginationResponse"}},"type":"object"},"ListClimateDisastersRequest":{"description":"ListClimateDisastersRequest specifies filters for retrieving climate disasters.","properties":{"cursor":{"description":"Cursor for next page.","type":"string"},"pageSize":{"description":"Maximum items per page (1-100).","format":"int32","type":"integer"}},"type":"object"},"ListClimateDisastersResponse":{"description":"ListClimateDisastersResponse contains climate disaster events.","properties":{"disasters":{"items":{"$ref":"#/components/schemas/ClimateDisaster"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PaginationResponse"}},"type":"object"},"PaginationResponse":{"description":"PaginationResponse contains pagination metadata returned alongside list results.","properties":{"nextCursor":{"description":"Cursor for fetching the next page. Empty string indicates no more pages.","type":"string"},"totalCount":{"description":"Total count of items matching the query, if known. Zero if the total is unknown.","format":"int32","type":"integer"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"ClimateService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/climate/v1/list-climate-anomalies":{"get":{"description":"ListClimateAnomalies retrieves temperature and precipitation anomalies from ERA5 data.","operationId":"ListClimateAnomalies","parameters":[{"description":"Maximum items per page (1-100).","in":"query","name":"page_size","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Cursor for next page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Optional filter by anomaly severity.","in":"query","name":"min_severity","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListClimateAnomaliesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"ListClimateAnomalies","tags":["ClimateService"]}},"/api/climate/v1/list-climate-disasters":{"get":{"description":"ListClimateDisasters retrieves climate-relevant disaster events from seeded data.","operationId":"ListClimateDisasters","parameters":[{"description":"Maximum items per page (1-100).","in":"query","name":"page_size","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Cursor for next page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListClimateDisastersResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"ListClimateDisasters","tags":["ClimateService"]}}}} \ No newline at end of file +{"components":{"schemas":{"ClimateAnomaly":{"description":"ClimateAnomaly represents a temperature or precipitation deviation from historical norms.\n Sourced from Open-Meteo / ERA5 reanalysis data.","properties":{"location":{"$ref":"#/components/schemas/GeoCoordinates"},"period":{"description":"Time period covered (e.g., \"2024-W03\", \"2024-01\").","minLength":1,"type":"string"},"precipDelta":{"description":"Precipitation deviation from normal as a percentage.","format":"double","type":"number"},"severity":{"description":"AnomalySeverity represents the severity of a climate anomaly.\n Maps to existing TS union: 'normal' | 'moderate' | 'extreme'.","enum":["ANOMALY_SEVERITY_UNSPECIFIED","ANOMALY_SEVERITY_NORMAL","ANOMALY_SEVERITY_MODERATE","ANOMALY_SEVERITY_EXTREME"],"type":"string"},"tempDelta":{"description":"Temperature deviation from normal in degrees Celsius.","format":"double","type":"number"},"type":{"description":"AnomalyType represents the type of climate anomaly.\n Maps to existing TS union: 'warm' | 'cold' | 'wet' | 'dry' | 'mixed'.","enum":["ANOMALY_TYPE_UNSPECIFIED","ANOMALY_TYPE_WARM","ANOMALY_TYPE_COLD","ANOMALY_TYPE_WET","ANOMALY_TYPE_DRY","ANOMALY_TYPE_MIXED"],"type":"string"},"zone":{"description":"Climate zone name (e.g., \"Northern Europe\", \"Sahel\").","minLength":1,"type":"string"}},"required":["zone","period"],"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"GeoCoordinates":{"description":"GeoCoordinates represents a geographic location using WGS84 coordinates.","properties":{"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","format":"double","maximum":90,"minimum":-90,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","format":"double","maximum":180,"minimum":-180,"type":"number"}},"type":"object"},"ListClimateAnomaliesRequest":{"description":"ListClimateAnomaliesRequest specifies filters for retrieving climate anomaly data.","properties":{"cursor":{"description":"Cursor for next page.","type":"string"},"minSeverity":{"description":"AnomalySeverity represents the severity of a climate anomaly.\n Maps to existing TS union: 'normal' | 'moderate' | 'extreme'.","enum":["ANOMALY_SEVERITY_UNSPECIFIED","ANOMALY_SEVERITY_NORMAL","ANOMALY_SEVERITY_MODERATE","ANOMALY_SEVERITY_EXTREME"],"type":"string"},"pageSize":{"description":"Maximum items per page (1-100).","format":"int32","type":"integer"}},"type":"object"},"ListClimateAnomaliesResponse":{"description":"ListClimateAnomaliesResponse contains the list of climate anomalies.","properties":{"anomalies":{"items":{"$ref":"#/components/schemas/ClimateAnomaly"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PaginationResponse"}},"type":"object"},"PaginationResponse":{"description":"PaginationResponse contains pagination metadata returned alongside list results.","properties":{"nextCursor":{"description":"Cursor for fetching the next page. Empty string indicates no more pages.","type":"string"},"totalCount":{"description":"Total count of items matching the query, if known. Zero if the total is unknown.","format":"int32","type":"integer"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"ClimateService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/climate/v1/list-climate-anomalies":{"get":{"description":"ListClimateAnomalies retrieves temperature and precipitation anomalies from ERA5 data.","operationId":"ListClimateAnomalies","parameters":[{"description":"Maximum items per page (1-100).","in":"query","name":"page_size","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Cursor for next page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Optional filter by anomaly severity.","in":"query","name":"min_severity","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListClimateAnomaliesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"ListClimateAnomalies","tags":["ClimateService"]}}}} \ No newline at end of file diff --git a/docs/api/ClimateService.openapi.yaml b/docs/api/ClimateService.openapi.yaml index 0e593454a4..fadfe6b047 100644 --- a/docs/api/ClimateService.openapi.yaml +++ b/docs/api/ClimateService.openapi.yaml @@ -49,46 +49,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /api/climate/v1/list-climate-disasters: - get: - tags: - - ClimateService - summary: ListClimateDisasters - description: ListClimateDisasters retrieves climate-relevant disaster events from seeded data. - operationId: ListClimateDisasters - parameters: - - name: page_size - in: query - description: Maximum items per page (1-100). - required: false - schema: - type: integer - format: int32 - - name: cursor - in: query - description: Cursor for next page. - required: false - schema: - type: string - responses: - "200": - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/ListClimateDisastersResponse' - "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: @@ -229,71 +189,3 @@ components: format: int32 description: Total count of items matching the query, if known. Zero if the total is unknown. description: PaginationResponse contains pagination metadata returned alongside list results. - ListClimateDisastersRequest: - type: object - properties: - pageSize: - type: integer - format: int32 - description: Maximum items per page (1-100). - cursor: - type: string - description: Cursor for next page. - description: ListClimateDisastersRequest specifies filters for retrieving climate disasters. - ListClimateDisastersResponse: - type: object - properties: - disasters: - type: array - items: - $ref: '#/components/schemas/ClimateDisaster' - pagination: - $ref: '#/components/schemas/PaginationResponse' - description: ListClimateDisastersResponse contains climate disaster events. - ClimateDisaster: - type: object - properties: - id: - type: string - description: Unique event identifier. - type: - type: string - description: 'Disaster type: flood, cyclone, drought, heatwave, wildfire.' - name: - type: string - description: Human-readable event name. - country: - type: string - description: Country name. - countryCode: - type: string - description: ISO 3166-1 alpha-2 country code. - lat: - type: number - format: double - description: Event latitude. - lng: - type: number - format: double - description: Event longitude. - severity: - type: string - description: 'Severity level. GDACS: green/orange/red. ReliefWeb: low/medium/high.' - startedAt: - type: integer - format: int64 - description: 'Event start time as Unix epoch milliseconds.. Warning: Values > 2^53 may lose precision in JavaScript' - status: - type: string - description: 'Event status: alert/ongoing/past.' - affectedPopulation: - type: integer - format: int32 - description: Affected population when available. - source: - type: string - description: 'Source system: GDACS, ReliefWeb, NASA FIRMS.' - sourceUrl: - type: string - description: Source URL for drill-down. - description: ClimateDisaster represents a climate-relevant disaster event from seeded caches. diff --git a/proto/worldmonitor/climate/v1/climate_disaster.proto b/proto/worldmonitor/climate/v1/climate_disaster.proto deleted file mode 100644 index 8991a7f4c4..0000000000 --- a/proto/worldmonitor/climate/v1/climate_disaster.proto +++ /dev/null @@ -1,35 +0,0 @@ -syntax = "proto3"; - -package worldmonitor.climate.v1; - -import "sebuf/http/annotations.proto"; - -// ClimateDisaster represents a climate-relevant disaster event from seeded caches. -message ClimateDisaster { - // Unique event identifier. - string id = 1; - // Disaster type: flood, cyclone, drought, heatwave, wildfire. - string type = 2; - // Human-readable event name. - string name = 3; - // Country name. - string country = 4; - // ISO 3166-1 alpha-2 country code. - string country_code = 5; - // Event latitude. - double lat = 6; - // Event longitude. - double lng = 7; - // Severity level. GDACS: green/orange/red. ReliefWeb: low/medium/high. - string severity = 8; - // Event start time as Unix epoch milliseconds. - int64 started_at = 9 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; - // Event status: alert/ongoing/past. - string status = 10; - // Affected population when available. - int32 affected_population = 11; - // Source system: GDACS, ReliefWeb, NASA FIRMS. - string source = 12; - // Source URL for drill-down. - string source_url = 13; -} diff --git a/proto/worldmonitor/climate/v1/list_climate_disasters.proto b/proto/worldmonitor/climate/v1/list_climate_disasters.proto deleted file mode 100644 index 160b3936bc..0000000000 --- a/proto/worldmonitor/climate/v1/list_climate_disasters.proto +++ /dev/null @@ -1,23 +0,0 @@ -syntax = "proto3"; - -package worldmonitor.climate.v1; - -import "sebuf/http/annotations.proto"; -import "worldmonitor/core/v1/pagination.proto"; -import "worldmonitor/climate/v1/climate_disaster.proto"; - -// ListClimateDisastersRequest specifies filters for retrieving climate disasters. -message ListClimateDisastersRequest { - // Maximum items per page (1-100). - int32 page_size = 1 [(sebuf.http.query) = { name: "page_size" }]; - // Cursor for next page. - string cursor = 2 [(sebuf.http.query) = { name: "cursor" }]; -} - -// ListClimateDisastersResponse contains climate disaster events. -message ListClimateDisastersResponse { - // The list of disasters. - repeated ClimateDisaster disasters = 1; - // Pagination metadata. - worldmonitor.core.v1.PaginationResponse pagination = 2; -} diff --git a/proto/worldmonitor/climate/v1/service.proto b/proto/worldmonitor/climate/v1/service.proto index f9df83699b..30731143be 100644 --- a/proto/worldmonitor/climate/v1/service.proto +++ b/proto/worldmonitor/climate/v1/service.proto @@ -4,7 +4,6 @@ package worldmonitor.climate.v1; import "sebuf/http/annotations.proto"; import "worldmonitor/climate/v1/list_climate_anomalies.proto"; -import "worldmonitor/climate/v1/list_climate_disasters.proto"; // ClimateService provides APIs for climate anomaly data sourced from Open-Meteo. service ClimateService { @@ -14,9 +13,4 @@ service ClimateService { rpc ListClimateAnomalies(ListClimateAnomaliesRequest) returns (ListClimateAnomaliesResponse) { option (sebuf.http.config) = {path: "/list-climate-anomalies", method: HTTP_METHOD_GET}; } - - // ListClimateDisasters retrieves climate-relevant disaster events from seeded data. - rpc ListClimateDisasters(ListClimateDisastersRequest) returns (ListClimateDisastersResponse) { - option (sebuf.http.config) = {path: "/list-climate-disasters", method: HTTP_METHOD_GET}; - } } diff --git a/scripts/railway-set-watch-paths.mjs b/scripts/railway-set-watch-paths.mjs index b653beead4..c5b2d38e0b 100644 --- a/scripts/railway-set-watch-paths.mjs +++ b/scripts/railway-set-watch-paths.mjs @@ -27,13 +27,8 @@ const API = 'https://backboard.railway.app/graphql/v2'; const USES_SHARED_CONFIG = new Set([ 'seed-commodity-quotes', 'seed-crypto-quotes', 'seed-etf-flows', 'seed-gulf-quotes', 'seed-market-quotes', 'seed-stablecoin-markets', - 'seed-climate-disasters', ]); -const EXTRA_WATCH_PATTERNS = { - 'seed-climate-disasters': ['public/data/countries.geojson'], -}; - function getToken() { if (process.env.RAILWAY_TOKEN) return process.env.RAILWAY_TOKEN; const cfgPath = join(homedir(), '.railway', 'config.json'); @@ -101,10 +96,6 @@ async function main() { patterns.push('scripts/shared/**', 'shared/**'); } - if (EXTRA_WATCH_PATTERNS[svc.name]) { - patterns.push(...EXTRA_WATCH_PATTERNS[svc.name]); - } - if (svc.name === 'seed-iran-events') { patterns.push('scripts/data/iran-events-latest.json'); } diff --git a/scripts/seed-climate-disasters.mjs b/scripts/seed-climate-disasters.mjs deleted file mode 100644 index 32fe209ce3..0000000000 --- a/scripts/seed-climate-disasters.mjs +++ /dev/null @@ -1,467 +0,0 @@ -#!/usr/bin/env node - -import { loadEnvFile, runSeed, CHROME_UA, verifySeedKey, loadSharedConfig } from './_seed-utils.mjs'; -import { extractCountryCode } from './shared/geo-extract.mjs'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -loadEnvFile(import.meta.url); - -const CANONICAL_KEY = 'climate:disasters:v1'; -const NATURAL_EVENTS_KEY = 'natural:events:v1'; -const CACHE_TTL = 21600; // 6h — aligned to a 6h cron cadence - -const RELIEFWEB_ENDPOINTS = [ - 'https://api.reliefweb.int/v1/disasters', - 'https://api.reliefweb.int/v2/disasters', -]; - -const RELIEFWEB_TYPE_TO_CANONICAL = { - FL: 'flood', - TC: 'cyclone', - DR: 'drought', - HT: 'heatwave', - WF: 'wildfire', -}; - -const COUNTRY_BBOXES = loadSharedConfig('country-bboxes.json'); -const __dirname = dirname(fileURLToPath(import.meta.url)); -const COUNTRY_GEOJSON = JSON.parse(readFileSync(join(__dirname, '..', 'public', 'data', 'countries.geojson'), 'utf8')); - -const COUNTRY_NAME_BY_CODE = {}; -const ISO3_TO_ISO2 = {}; - -for (const feature of COUNTRY_GEOJSON?.features || []) { - const properties = feature?.properties || {}; - const iso2 = String(properties['ISO3166-1-Alpha-2'] || '').trim().toUpperCase(); - const iso3 = String(properties['ISO3166-1-Alpha-3'] || '').trim().toUpperCase(); - const name = String(properties.name || '').trim(); - if (/^[A-Z]{2}$/.test(iso2) && name) COUNTRY_NAME_BY_CODE[iso2] = name; - if (/^[A-Z]{2}$/.test(iso2) && /^[A-Z]{3}$/.test(iso3)) ISO3_TO_ISO2[iso3] = iso2; -} - -const COUNTRY_CODES_BY_BBOX_AREA = Object.entries(COUNTRY_BBOXES) - .filter(([, bbox]) => Array.isArray(bbox) && bbox.length === 4) - .sort(([, a], [, b]) => { - const areaA = Math.abs((Number(a[2]) - Number(a[0])) * (Number(a[3]) - Number(a[1]))); - const areaB = Math.abs((Number(b[2]) - Number(b[0])) * (Number(b[3]) - Number(b[1]))); - return areaA - areaB; - }) - .map(([code]) => code); - -function asArray(value) { - if (Array.isArray(value)) return value; - if (value == null) return []; - return [value]; -} - -function stableHash(str) { - let h = 0; - for (let i = 0; i < str.length; i++) h = (Math.imul(31, h) + str.charCodeAt(i)) | 0; - return Math.abs(h).toString(36); -} - -function parseTimestamp(value) { - const ts = new Date(value || '').getTime(); - return Number.isFinite(ts) && ts > 0 ? ts : Date.now(); -} - -function normalizeStatus(status) { - const value = String(status || '').toLowerCase().trim(); - if (value === 'current') return 'ongoing'; - if (value === 'alert' || value === 'ongoing' || value === 'past') return value; - return 'ongoing'; -} - -function normalizeDisasterName(value) { - return String(value || '') - .replace(/^[\u{1F534}\u{1F7E0}\u{1F7E2}\s-]+/u, '') - .trim(); -} - -function mapReliefType(typeCode, typeName) { - const code = String(typeCode || '').toUpperCase(); - if (RELIEFWEB_TYPE_TO_CANONICAL[code]) return RELIEFWEB_TYPE_TO_CANONICAL[code]; - const lower = String(typeName || '').toLowerCase(); - if (lower.includes('flood')) return 'flood'; - if (lower.includes('cyclone') || lower.includes('hurricane') || lower.includes('typhoon') || lower.includes('storm')) return 'cyclone'; - if (lower.includes('drought')) return 'drought'; - if (lower.includes('heat')) return 'heatwave'; - if (lower.includes('wildfire') || lower.includes('fire')) return 'wildfire'; - return ''; -} - -function getNaturalSourceMeta(event) { - const sourceText = `${event?.sourceName || ''} ${event?.sourceUrl || ''}`.toLowerCase(); - if (sourceText.includes('firms')) return { source: 'NASA FIRMS' }; - if (sourceText.includes('gdacs') || String(event?.id || '').startsWith('gdacs-')) return { source: 'GDACS' }; - return null; -} - -function isClimateNaturalEvent(event) { - if (!event || typeof event !== 'object') return false; - const sourceMeta = getNaturalSourceMeta(event); - if (!sourceMeta) return false; - - if (event.category === 'floods' || event.category === 'wildfires') return true; - if (event.category !== 'severeStorms') return false; - if (sourceMeta.source !== 'GDACS') return false; - - const text = `${event.categoryTitle || ''} ${event.classification || ''} ${event.title || ''}`.toLowerCase(); - if (event.stormId || event.stormName) return true; - return /tropical|cyclone|hurricane|typhoon|depression/.test(text); -} - -function mapNaturalType(event) { - if (event.category === 'floods') return 'flood'; - if (event.category === 'wildfires') return 'wildfire'; - if (event.category === 'severeStorms') return 'cyclone'; - return ''; -} - -function mapNaturalSource(event) { - return getNaturalSourceMeta(event)?.source || ''; -} - -function mapNaturalSeverity(event, source) { - const title = String(event.title || ''); - const desc = String(event.description || '').toLowerCase(); - const stormCategory = Number(event.stormCategory); - - if (title.includes('\u{1F534}') || /\bred\b/.test(desc)) return 'red'; - if (title.includes('\u{1F7E0}') || /\borange\b/.test(desc)) return 'orange'; - if (Number.isFinite(stormCategory)) { - if (stormCategory >= 3) return 'red'; - if (stormCategory >= 1) return 'orange'; - } - if (source === 'NASA FIRMS') { - const magnitude = Number(event.magnitude || 0); - if (magnitude >= 400) return 'red'; - if (magnitude >= 300) return 'orange'; - } - return 'green'; -} - -function mapNaturalStatus(event, severity) { - if (event.closed === true) return 'past'; - if (severity === 'red' || severity === 'orange') return 'alert'; - return 'ongoing'; -} - -function getCountryCenter(countryCode) { - const bbox = COUNTRY_BBOXES[countryCode]; - if (!Array.isArray(bbox) || bbox.length !== 4) return { lat: 0, lng: 0 }; - return { - lat: (Number(bbox[0]) + Number(bbox[2])) / 2, - lng: (Number(bbox[1]) + Number(bbox[3])) / 2, - }; -} - -function normalizeCountryCode(code) { - const value = String(code || '').toUpperCase(); - return /^[A-Z]{2}$/.test(value) ? value : ''; -} - -function getCountryName(countryCode) { - return COUNTRY_NAME_BY_CODE[normalizeCountryCode(countryCode)] || ''; -} - -function getCountryCodeFromIso3(code) { - const value = String(code || '').toUpperCase(); - return /^[A-Z]{3}$/.test(value) ? (ISO3_TO_ISO2[value] || '') : ''; -} - -function findCountryCodeByCoordinates(lat, lng) { - const latNum = Number(lat); - const lngNum = Number(lng); - if (!Number.isFinite(latNum) || !Number.isFinite(lngNum)) return ''; - for (const code of COUNTRY_CODES_BY_BBOX_AREA) { - const bbox = COUNTRY_BBOXES[code]; - if (!Array.isArray(bbox) || bbox.length !== 4) continue; - const [minLat, minLng, maxLat, maxLng] = bbox.map(Number); - if (latNum >= minLat && latNum <= maxLat && lngNum >= minLng && lngNum <= maxLng) { - return code; - } - } - return ''; -} - -function resolveCountryInfo({ code = '', iso3 = '', name = '', lat = NaN, lng = NaN, fallbackText = '' } = {}) { - const normalizedName = String(name || '').trim(); - const fromIso2 = normalizeCountryCode(code); - const fromIso3 = getCountryCodeFromIso3(iso3); - const fromText = normalizeCountryCode(extractCountryCode(`${normalizedName} ${fallbackText}`)); - const fromPoint = findCountryCodeByCoordinates(lat, lng); - const countryCode = fromIso2 || fromIso3 || fromText || fromPoint; - return { - countryCode, - country: normalizedName || getCountryName(countryCode), - }; -} - -// TODO(maintainer): Set Railway RELIEFWEB_APPNAME to an approved ReliefWeb -// appname before enabling this seed, or ReliefWeb fetches will fail closed. -function getReliefWebAppname() { - const appname = String(process.env.RELIEFWEB_APPNAME || process.env.RELIEFWEB_APP_NAME || '').trim(); - if (!appname) { - throw new Error('RELIEFWEB_APPNAME is required; ReliefWeb now requires a pre-approved appname'); - } - return appname; -} - -function buildReliefWebRequestBodies() { - return [ - { - limit: 250, - sort: ['date.event:desc'], - fields: { - include: ['name', 'country', 'primary_country', 'primary_type', 'type', 'date', 'glide', 'status', 'url'], - }, - filter: { - operator: 'AND', - conditions: [ - { field: 'status', value: ['alert', 'current', 'ongoing'], operator: 'OR' }, - { field: 'type.code', value: ['FL', 'TC', 'DR', 'HT', 'WF'], operator: 'OR' }, - ], - }, - }, - ]; -} - -function mapReliefItem(item) { - const fields = item?.fields || {}; - - const typedEntries = asArray(fields.type); - const primaryType = typedEntries.find((entry) => entry?.primary) || typedEntries[0] || {}; - const fallbackPrimaryType = asArray(fields.primary_type)[0] || {}; - const type = mapReliefType(primaryType.code, primaryType.name || fallbackPrimaryType.name); - if (!type) return null; - - const status = normalizeStatus(fields.status); - if (status !== 'alert' && status !== 'ongoing') return null; - - const countries = [ - ...asArray(fields.primary_country), - ...asArray(fields.country), - ]; - const countryEntry = countries.find((country) => country?.primary) || countries[0] || {}; - const { country, countryCode } = resolveCountryInfo({ - code: countryEntry.code, - iso3: countryEntry.iso3, - name: countryEntry.shortname || countryEntry.name, - fallbackText: fields.name, - }); - const coords = getCountryCenter(countryCode); - - const startedAt = parseTimestamp(fields?.date?.event || fields?.date?.created || fields?.date?.changed); - const name = normalizeDisasterName(fields.name || ''); - const reliefId = fields.glide || item?.id || stableHash(`${name}-${country}-${startedAt}`); - - return { - id: `reliefweb-${reliefId}`, - type, - name, - country, - countryCode, - lat: coords.lat, - lng: coords.lng, - severity: status === 'alert' ? 'high' : 'medium', - startedAt, - status, - affectedPopulation: 0, - source: 'ReliefWeb', - sourceUrl: String(fields.url || '').trim(), - }; -} - -async function fetchReliefWeb() { - const appname = getReliefWebAppname(); - const requestBodies = buildReliefWebRequestBodies(); - - let lastError = null; - for (const endpoint of RELIEFWEB_ENDPOINTS) { - for (const body of requestBodies) { - try { - const url = `${endpoint}?appname=${encodeURIComponent(appname)}`; - const response = await fetch(url, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'User-Agent': CHROME_UA, - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(20_000), - }); - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(`HTTP ${response.status} ${text.slice(0, 160)}`); - } - - const payload = await response.json(); - const rows = asArray(payload?.data); - if (!rows.length) continue; - - const mapped = rows.map(mapReliefItem).filter(Boolean); - if (mapped.length > 0) return mapped; - } catch (err) { - lastError = err; - const message = String(err?.message || err); - if (/approved appname/i.test(message)) { - throw new Error(`ReliefWeb rejected RELIEFWEB_APPNAME="${appname}" — configure an approved appname`); - } - } - } - } - - if (lastError) throw lastError; - throw new Error('ReliefWeb returned no climate disaster rows'); -} - -function mapNaturalEvent(event) { - const type = mapNaturalType(event); - if (!type) return null; - - const source = mapNaturalSource(event); - if (source !== 'GDACS' && source !== 'NASA FIRMS') return null; - const severity = mapNaturalSeverity(event, source); - const status = mapNaturalStatus(event, severity); - const lat = Number(event.lat); - const lng = Number(event.lon); - const { country, countryCode } = resolveCountryInfo({ - lat, - lng, - fallbackText: `${event.title || ''} ${event.description || ''}`, - }); - const startedAt = parseTimestamp(event.date); - - return { - id: String(event.id || stableHash(`${event.title || ''}-${startedAt}`)), - type, - name: normalizeDisasterName(event.title || event.stormName || event.categoryTitle || 'Untitled disaster'), - country, - countryCode, - lat: Number.isFinite(lat) ? lat : 0, - lng: Number.isFinite(lng) ? lng : 0, - severity, - startedAt, - status, - affectedPopulation: 0, - source, - sourceUrl: String(event.sourceUrl || '').trim(), - }; -} - -async function fetchNaturalClimateDisasters() { - const data = await verifySeedKey(NATURAL_EVENTS_KEY).catch(() => null); - const events = asArray(data?.events); - return events - .filter(isClimateNaturalEvent) - .map(mapNaturalEvent) - .filter(Boolean); -} - -function dedupeAndSort(entries) { - const byId = new Set(); - const byFingerprint = new Set(); - const deduped = []; - - for (const entry of entries) { - const idKey = `${entry.source}:${entry.id}`; - if (byId.has(idKey)) continue; - byId.add(idKey); - - const dayBucket = Math.floor(Number(entry.startedAt || 0) / 86_400_000); - const fingerprint = [ - entry.type, - entry.countryCode || entry.country || '', - String(entry.name || '').toLowerCase(), - dayBucket, - ].join('|'); - if (byFingerprint.has(fingerprint)) continue; - byFingerprint.add(fingerprint); - - deduped.push(entry); - } - - deduped.sort((a, b) => Number(b.startedAt || 0) - Number(a.startedAt || 0)); - return deduped.slice(0, 300); -} - -function toRedisDisaster(entry) { - return { - id: String(entry.id || ''), - type: String(entry.type || ''), - name: String(entry.name || ''), - country: String(entry.country || ''), - country_code: String(entry.countryCode || ''), - lat: Number(entry.lat || 0), - lng: Number(entry.lng || 0), - severity: String(entry.severity || ''), - started_at: Number(entry.startedAt || 0), - status: String(entry.status || ''), - affected_population: Number(entry.affectedPopulation || 0), - source: String(entry.source || ''), - source_url: String(entry.sourceUrl || ''), - }; -} - -function collectDisasterSourceResults(results) { - const failures = []; - const combined = []; - - for (const result of results) { - if (result.status === 'fulfilled') { - combined.push(...asArray(result.value)); - continue; - } - failures.push(result.reason); - const message = String(result.reason?.message || result.reason || 'unknown source failure'); - console.warn(`[seed-climate-disasters] partial source failure: ${message}`); - } - - const disasters = dedupeAndSort(combined); - if (disasters.length > 0) return disasters; - - const errorMessages = failures - .map((err) => String(err?.message || err || '').trim()) - .filter(Boolean); - throw new Error(errorMessages[0] || 'No climate disaster sources returned data'); -} - -async function fetchClimateDisasters() { - const results = await Promise.allSettled([ - fetchReliefWeb(), - fetchNaturalClimateDisasters(), - ]); - return { disasters: collectDisasterSourceResults(results).map(toRedisDisaster) }; -} - -export { - buildReliefWebRequestBodies, - collectDisasterSourceResults, - getNaturalSourceMeta, - getReliefWebAppname, - isClimateNaturalEvent, - findCountryCodeByCoordinates, - mapNaturalEvent, - toRedisDisaster, -}; - -function isMain() { - return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href; -} - -if (isMain()) { - runSeed('climate', 'disasters', CANONICAL_KEY, fetchClimateDisasters, { - validateFn: (data) => Array.isArray(data?.disasters) && data.disasters.length > 0, - recordCount: (data) => data?.disasters?.length || 0, - ttlSeconds: CACHE_TTL, - sourceVersion: 'reliefweb+natural-cache-v1', - }).catch((err) => { - const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : ''; - console.error('FATAL:', (err.message || err) + _cause); - process.exit(1); - }); -} diff --git a/server/_shared/cache-keys.ts b/server/_shared/cache-keys.ts index c24a6d1656..fb48505afd 100644 --- a/server/_shared/cache-keys.ts +++ b/server/_shared/cache-keys.ts @@ -28,7 +28,6 @@ export const BOOTSTRAP_CACHE_KEYS: Record = { minerals: 'supply_chain:minerals:v2', giving: 'giving:summary:v1', climateAnomalies: 'climate:anomalies:v1', - climateDisasters: 'climate:disasters:v1', radiationWatch: 'radiation:observations:v1', thermalEscalation: 'thermal:escalation:v1', crossSourceSignals: 'intelligence:cross-source-signals:v1', @@ -89,7 +88,7 @@ export const BOOTSTRAP_TIERS: Record = { minerals: 'slow', giving: 'slow', sectors: 'slow', progressData: 'slow', renewableEnergy: 'slow', etfFlows: 'slow', shippingRates: 'fast', wildfires: 'slow', - climateAnomalies: 'slow', climateDisasters: 'slow', sanctionsPressure: 'slow', radiationWatch: 'slow', thermalEscalation: 'slow', crossSourceSignals: 'slow', cyberThreats: 'slow', techReadiness: 'slow', + climateAnomalies: 'slow', sanctionsPressure: 'slow', radiationWatch: 'slow', thermalEscalation: 'slow', crossSourceSignals: 'slow', cyberThreats: 'slow', techReadiness: 'slow', theaterPosture: 'fast', naturalEvents: 'slow', cryptoQuotes: 'slow', gulfQuotes: 'slow', stablecoinMarkets: 'slow', unrestEvents: 'slow', ucdpEvents: 'slow', techEvents: 'slow', diff --git a/server/gateway.ts b/server/gateway.ts index 3feda03b82..799700b3ac 100644 --- a/server/gateway.ts +++ b/server/gateway.ts @@ -107,7 +107,6 @@ const RPC_CACHE_TIER: Record = { '/api/intelligence/v1/get-country-intel-brief': 'static', '/api/intelligence/v1/get-gdelt-topic-timeline': 'medium', '/api/climate/v1/list-climate-anomalies': 'static', - '/api/climate/v1/list-climate-disasters': 'static', '/api/sanctions/v1/list-sanctions-pressure': 'static', '/api/sanctions/v1/lookup-sanction-entity': 'no-store', '/api/radiation/v1/list-radiation-observations': 'slow', diff --git a/server/worldmonitor/climate/v1/handler.ts b/server/worldmonitor/climate/v1/handler.ts index b8c1dc8217..40e51c0f0d 100644 --- a/server/worldmonitor/climate/v1/handler.ts +++ b/server/worldmonitor/climate/v1/handler.ts @@ -1,9 +1,7 @@ import type { ClimateServiceHandler } from '../../../../src/generated/server/worldmonitor/climate/v1/service_server'; import { listClimateAnomalies } from './list-climate-anomalies'; -import { listClimateDisasters } from './list-climate-disasters'; export const climateHandler: ClimateServiceHandler = { listClimateAnomalies, - listClimateDisasters, }; diff --git a/server/worldmonitor/climate/v1/list-climate-disasters.ts b/server/worldmonitor/climate/v1/list-climate-disasters.ts deleted file mode 100644 index c62cf5ac79..0000000000 --- a/server/worldmonitor/climate/v1/list-climate-disasters.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * ListClimateDisasters RPC -- reads seeded climate disaster data from Railway seed cache. - * ReliefWeb and natural-event transforms happen in seed-climate-disasters.mjs on Railway. - */ - -import type { - ClimateServiceHandler, - ServerContext, - ListClimateDisastersRequest, - ListClimateDisastersResponse, - ClimateDisaster, -} from '../../../../src/generated/server/worldmonitor/climate/v1/service_server'; - -import { getCachedJson } from '../../../_shared/redis'; - -const SEED_CACHE_KEY = 'climate:disasters:v1'; -const DEFAULT_LIMIT = 100; -const MAX_LIMIT = 100; - -function clampInt(value: number, fallback: number, min: number, max: number): number { - const num = Number(value); - if (!Number.isFinite(num)) return fallback; - return Math.max(min, Math.min(max, Math.trunc(num))); -} - -function parseCursor(cursor: string | undefined): number { - const num = parseInt(String(cursor || ''), 10); - return Number.isFinite(num) && num >= 0 ? num : 0; -} - -function asNumber(value: unknown, fallback = 0): number { - const num = Number(value); - return Number.isFinite(num) ? num : fallback; -} - -function normalizeCachedDisaster(row: unknown): ClimateDisaster | null { - if (!row || typeof row !== 'object') return null; - - const record = row as Record; - const id = String(record.id || '').trim(); - if (!id) return null; - - return { - id, - type: String(record.type || ''), - name: String(record.name || ''), - country: String(record.country || ''), - countryCode: String(record.countryCode || record.country_code || ''), - lat: asNumber(record.lat, 0), - lng: asNumber(record.lng, 0), - severity: String(record.severity || ''), - startedAt: asNumber(record.startedAt ?? record.started_at, 0), - status: String(record.status || ''), - affectedPopulation: asNumber(record.affectedPopulation ?? record.affected_population, 0), - source: String(record.source || ''), - sourceUrl: String(record.sourceUrl || record.source_url || ''), - }; -} - -export const listClimateDisasters: ClimateServiceHandler['listClimateDisasters'] = async ( - _ctx: ServerContext, - req: ListClimateDisastersRequest, -): Promise => { - try { - const limit = clampInt(req.pageSize, DEFAULT_LIMIT, 1, MAX_LIMIT); - const offset = parseCursor(req.cursor); - const result = await getCachedJson(SEED_CACHE_KEY, true) as { disasters?: unknown[] } | null; - const allDisasters = Array.isArray(result?.disasters) - ? result.disasters.map(normalizeCachedDisaster).filter((row): row is ClimateDisaster => row != null) - : []; - if (offset >= allDisasters.length) { - return { - disasters: [], - pagination: { nextCursor: '', totalCount: allDisasters.length }, - }; - } - - const disasters = allDisasters.slice(offset, offset + limit); - const hasMore = offset + limit < allDisasters.length; - return { - disasters, - pagination: { - nextCursor: hasMore ? String(offset + limit) : '', - totalCount: allDisasters.length, - }, - }; - } catch { - return { - disasters: [], - pagination: { nextCursor: '', totalCount: 0 }, - }; - } -}; diff --git a/src/generated/client/worldmonitor/climate/v1/service_client.ts b/src/generated/client/worldmonitor/climate/v1/service_client.ts index 3dbb51ae30..dfdd369e0f 100644 --- a/src/generated/client/worldmonitor/climate/v1/service_client.ts +++ b/src/generated/client/worldmonitor/climate/v1/service_client.ts @@ -33,32 +33,6 @@ export interface PaginationResponse { totalCount: number; } -export interface ListClimateDisastersRequest { - pageSize: number; - cursor: string; -} - -export interface ListClimateDisastersResponse { - disasters: ClimateDisaster[]; - pagination?: PaginationResponse; -} - -export interface ClimateDisaster { - id: string; - type: string; - name: string; - country: string; - countryCode: string; - lat: number; - lng: number; - severity: string; - startedAt: number; - status: string; - affectedPopulation: number; - source: string; - sourceUrl: string; -} - export type AnomalySeverity = "ANOMALY_SEVERITY_UNSPECIFIED" | "ANOMALY_SEVERITY_NORMAL" | "ANOMALY_SEVERITY_MODERATE" | "ANOMALY_SEVERITY_EXTREME"; export type AnomalyType = "ANOMALY_TYPE_UNSPECIFIED" | "ANOMALY_TYPE_WARM" | "ANOMALY_TYPE_COLD" | "ANOMALY_TYPE_WET" | "ANOMALY_TYPE_DRY" | "ANOMALY_TYPE_MIXED"; @@ -138,32 +112,6 @@ export class ClimateServiceClient { return await resp.json() as ListClimateAnomaliesResponse; } - async listClimateDisasters(req: ListClimateDisastersRequest, options?: ClimateServiceCallOptions): Promise { - let path = "/api/climate/v1/list-climate-disasters"; - const params = new URLSearchParams(); - if (req.pageSize != null && req.pageSize !== 0) params.set("page_size", String(req.pageSize)); - if (req.cursor != null && req.cursor !== "") params.set("cursor", String(req.cursor)); - const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); - - const headers: Record = { - "Content-Type": "application/json", - ...this.defaultHeaders, - ...options?.headers, - }; - - const resp = await this.fetchFn(url, { - method: "GET", - headers, - signal: options?.signal, - }); - - if (!resp.ok) { - return this.handleError(resp); - } - - return await resp.json() as ListClimateDisastersResponse; - } - private async handleError(resp: Response): Promise { const body = await resp.text(); if (resp.status === 400) { diff --git a/src/generated/server/worldmonitor/climate/v1/service_server.ts b/src/generated/server/worldmonitor/climate/v1/service_server.ts index 64e2fd3ff6..5465e283a4 100644 --- a/src/generated/server/worldmonitor/climate/v1/service_server.ts +++ b/src/generated/server/worldmonitor/climate/v1/service_server.ts @@ -33,32 +33,6 @@ export interface PaginationResponse { totalCount: number; } -export interface ListClimateDisastersRequest { - pageSize: number; - cursor: string; -} - -export interface ListClimateDisastersResponse { - disasters: ClimateDisaster[]; - pagination?: PaginationResponse; -} - -export interface ClimateDisaster { - id: string; - type: string; - name: string; - country: string; - countryCode: string; - lat: number; - lng: number; - severity: string; - startedAt: number; - status: string; - affectedPopulation: number; - source: string; - sourceUrl: string; -} - export type AnomalySeverity = "ANOMALY_SEVERITY_UNSPECIFIED" | "ANOMALY_SEVERITY_NORMAL" | "ANOMALY_SEVERITY_MODERATE" | "ANOMALY_SEVERITY_EXTREME"; export type AnomalyType = "ANOMALY_TYPE_UNSPECIFIED" | "ANOMALY_TYPE_WARM" | "ANOMALY_TYPE_COLD" | "ANOMALY_TYPE_WET" | "ANOMALY_TYPE_DRY" | "ANOMALY_TYPE_MIXED"; @@ -109,7 +83,6 @@ export interface RouteDescriptor { export interface ClimateServiceHandler { listClimateAnomalies(ctx: ServerContext, req: ListClimateAnomaliesRequest): Promise; - listClimateDisasters(ctx: ServerContext, req: ListClimateDisastersRequest): Promise; } export function createClimateServiceRoutes( @@ -166,54 +139,6 @@ export function createClimateServiceRoutes( } }, }, - { - method: "GET", - path: "/api/climate/v1/list-climate-disasters", - handler: async (req: Request): Promise => { - try { - const pathParams: Record = {}; - const url = new URL(req.url, "http://localhost"); - const params = url.searchParams; - const body: ListClimateDisastersRequest = { - pageSize: Number(params.get("page_size") ?? "0"), - cursor: params.get("cursor") ?? "", - }; - if (options?.validateRequest) { - const bodyViolations = options.validateRequest("listClimateDisasters", body); - if (bodyViolations) { - throw new ValidationError(bodyViolations); - } - } - - const ctx: ServerContext = { - request: req, - pathParams, - headers: Object.fromEntries(req.headers.entries()), - }; - - const result = await handler.listClimateDisasters(ctx, body); - return new Response(JSON.stringify(result as ListClimateDisastersResponse), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } catch (err: unknown) { - if (err instanceof ValidationError) { - return new Response(JSON.stringify({ violations: err.violations }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - if (options?.onError) { - return options.onError(err, req); - } - const message = err instanceof Error ? err.message : String(err); - return new Response(JSON.stringify({ message }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); - } - }, - }, ]; } diff --git a/src/services/climate/index.ts b/src/services/climate/index.ts index ce3ca139b9..866b4f77df 100644 --- a/src/services/climate/index.ts +++ b/src/services/climate/index.ts @@ -5,7 +5,6 @@ import { type AnomalySeverity as ProtoAnomalySeverity, type AnomalyType as ProtoAnomalyType, type ListClimateAnomaliesResponse, - type ListClimateDisastersResponse, } from '@/generated/client/worldmonitor/climate/v1/service_client'; import { createCircuitBreaker } from '@/utils'; import { getHydratedData } from '@/services/bootstrap'; @@ -61,14 +60,6 @@ export async function fetchClimateAnomalies(): Promise { return { ok: true, anomalies }; } -/** - * Dedicated hydration accessor for climate disaster bootstrap payload. - * Kept separate from anomalies to avoid consuming unrelated hydrated data. - */ -export function getHydratedClimateDisasters(): ListClimateDisastersResponse | undefined { - return getHydratedData('climateDisasters') as ListClimateDisastersResponse | undefined; -} - // Presentation helpers (used by ClimateAnomalyPanel) export function getSeverityIcon(anomaly: ClimateAnomaly): string { switch (anomaly.type) { diff --git a/tests/climate-disasters-seed.test.mjs b/tests/climate-disasters-seed.test.mjs deleted file mode 100644 index 060575d74a..0000000000 --- a/tests/climate-disasters-seed.test.mjs +++ /dev/null @@ -1,175 +0,0 @@ -import { afterEach, describe, it } from 'node:test'; -import assert from 'node:assert/strict'; - -import { - buildReliefWebRequestBodies, - collectDisasterSourceResults, - findCountryCodeByCoordinates, - getReliefWebAppname, - isClimateNaturalEvent, - mapNaturalEvent, - toRedisDisaster, -} from '../scripts/seed-climate-disasters.mjs'; - -const ORIGINAL_APPNAME = process.env.RELIEFWEB_APPNAME; -const ORIGINAL_ALT_APPNAME = process.env.RELIEFWEB_APP_NAME; - -afterEach(() => { - if (ORIGINAL_APPNAME == null) delete process.env.RELIEFWEB_APPNAME; - else process.env.RELIEFWEB_APPNAME = ORIGINAL_APPNAME; - - if (ORIGINAL_ALT_APPNAME == null) delete process.env.RELIEFWEB_APP_NAME; - else process.env.RELIEFWEB_APP_NAME = ORIGINAL_ALT_APPNAME; -}); - -describe('seed-climate-disasters helpers', () => { - it('uses the documented ReliefWeb disaster type filter', () => { - const [body] = buildReliefWebRequestBodies(); - const typeFilter = body.filter.conditions.find((condition) => condition.field.includes('type')); - - assert.equal(typeFilter.field, 'type.code'); - assert.deepEqual(typeFilter.value, ['FL', 'TC', 'DR', 'HT', 'WF']); - }); - - it('requires an approved ReliefWeb appname to be configured', () => { - delete process.env.RELIEFWEB_APPNAME; - delete process.env.RELIEFWEB_APP_NAME; - - assert.throws( - () => getReliefWebAppname(), - /RELIEFWEB_APPNAME is required/, - ); - }); - - it('only reuses GDACS or NASA FIRMS items from natural:events:v1', () => { - assert.equal( - isClimateNaturalEvent({ category: 'floods', sourceName: 'GDACS', id: 'gdacs-FL-123' }), - true, - ); - assert.equal( - isClimateNaturalEvent({ category: 'wildfires', sourceName: 'NASA FIRMS', id: 'EONET_1' }), - true, - ); - assert.equal( - isClimateNaturalEvent({ category: 'severeStorms', sourceName: 'NHC', stormName: 'Alfred', id: 'nhc-AL01-1' }), - false, - ); - assert.equal( - isClimateNaturalEvent({ category: 'wildfires', sourceName: 'Volcanic Ash Advisory', id: 'EONET_2' }), - false, - ); - }); - - it('preserves supported natural-event provenance and rejects unsupported rows', () => { - const firmsEvent = mapNaturalEvent({ - id: 'EONET_3', - category: 'wildfires', - title: 'Wildfire near Santa Clarita', - description: '', - sourceName: 'NASA FIRMS', - sourceUrl: 'https://firms.modaps.eosdis.nasa.gov/', - magnitude: 350, - date: 1_700_000_000_000, - lat: 34.4, - lon: -118.5, - }); - assert.equal(firmsEvent.source, 'NASA FIRMS'); - assert.equal(firmsEvent.severity, 'orange'); - - const gdacsEvent = mapNaturalEvent({ - id: 'gdacs-TC-123', - category: 'severeStorms', - title: '\u{1F534} Cyclone Jude', - description: 'Landfall expected', - sourceName: 'GDACS', - sourceUrl: 'https://www.gdacs.org/', - stormName: 'Jude', - stormCategory: 4, - date: 1_700_000_000_000, - lat: -18.9, - lon: 36.2, - }); - assert.equal(gdacsEvent.source, 'GDACS'); - assert.equal(gdacsEvent.severity, 'red'); - - assert.equal( - mapNaturalEvent({ - id: 'nhc-AL01-1', - category: 'severeStorms', - title: 'Tropical Storm Alfred', - sourceName: 'NHC', - sourceUrl: 'https://www.nhc.noaa.gov/', - date: 1_700_000_000_000, - lat: 20, - lon: -70, - }), - null, - ); - }); - - it('derives country codes from coordinates when natural-event text lacks a country', () => { - assert.equal(findCountryCodeByCoordinates(35.6762, 139.6503), 'JP'); - - const gdacsEvent = mapNaturalEvent({ - id: 'gdacs-TC-456', - category: 'severeStorms', - title: '\u{1F7E0} Tropical Cyclone', - description: '', - sourceName: 'GDACS', - sourceUrl: 'https://www.gdacs.org/', - date: 1_700_000_000_000, - lat: 35.6762, - lon: 139.6503, - }); - assert.equal(gdacsEvent.countryCode, 'JP'); - assert.equal(gdacsEvent.country, 'Japan'); - }); - - it('keeps successful source payloads when another source fails', () => { - const merged = collectDisasterSourceResults([ - { status: 'fulfilled', value: [{ id: 'relief-1', source: 'ReliefWeb', type: 'flood', name: 'Floods', country: 'Japan', countryCode: 'JP', lat: 35.6, lng: 139.7, severity: 'high', startedAt: 1_700_000_000_000, status: 'alert', affectedPopulation: 0, sourceUrl: 'https://reliefweb.int/' }] }, - { status: 'rejected', reason: new Error('natural cache unavailable') }, - ]); - - assert.equal(merged.length, 1); - assert.equal(merged[0].source, 'ReliefWeb'); - }); - - it('emits the required snake_case Redis output shape', () => { - const row = toRedisDisaster({ - id: 'gdacs-TC-123', - type: 'cyclone', - name: 'Cyclone Jude', - country: 'Japan', - countryCode: 'JP', - lat: 35.6, - lng: 139.7, - severity: 'red', - startedAt: 1_700_000_000_000, - status: 'alert', - affectedPopulation: 42, - source: 'GDACS', - sourceUrl: 'https://www.gdacs.org/', - }); - - assert.deepEqual(Object.keys(row), [ - 'id', - 'type', - 'name', - 'country', - 'country_code', - 'lat', - 'lng', - 'severity', - 'started_at', - 'status', - 'affected_population', - 'source', - 'source_url', - ]); - assert.equal(row.country_code, 'JP'); - assert.equal(row.started_at, 1_700_000_000_000); - assert.equal(row.affected_population, 42); - assert.equal(row.source_url, 'https://www.gdacs.org/'); - }); -}); diff --git a/tests/ttl-acled-ais-guards.test.mjs b/tests/ttl-acled-ais-guards.test.mjs index 03af4d3fd2..4db625e7fa 100644 --- a/tests/ttl-acled-ais-guards.test.mjs +++ b/tests/ttl-acled-ais-guards.test.mjs @@ -30,14 +30,6 @@ describe('cache-only handlers read from seed keys', () => { 'Climate handler should not call external APIs'); }); - it('climate disasters is pure cache read (seed controls TTL)', () => { - const src = readSrc('server/worldmonitor/climate/v1/list-climate-disasters.ts'); - assert.match(src, /getCachedJson/, - 'Climate disasters handler should use getCachedJson (seed-only)'); - assert.doesNotMatch(src, /cachedFetchJson/, - 'Climate disasters handler should not call external APIs'); - }); - it('fire detections is pure cache read (seed controls TTL)', () => { const src = readSrc('server/worldmonitor/wildfire/v1/list-fire-detections.ts'); assert.match(src, /getCachedJson/,