Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ EIA_API_KEY=
FRED_API_KEY=


# ------ Air Quality Intelligence (Railway seed) ------

# OpenAQ API v3 (required for scripts/seed-health-air-quality.mjs)
# Register at: https://docs.openaq.org/using-the-api/api-key
OPENAQ_API_KEY=

# WAQI API (optional supplement for additional city/station coverage)
# Register at: https://aqicn.org/data-platform/token/
WAQI_API_KEY=


# ------ Aviation Intelligence (Vercel) ------

# AviationStack (live flight data, airport flights, carrier ops)
Expand Down
64 changes: 63 additions & 1 deletion api/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const BOOTSTRAP_CACHE_KEYS = {
minerals: 'supply_chain:minerals:v2',
giving: 'giving:summary:v1',
climateAnomalies: 'climate:anomalies:v2',
climateAirQuality: 'climate:air-quality:v1',
co2Monitoring: 'climate:co2-monitoring:v1',
climateNews: 'climate:news-intelligence:v1',
radiationWatch: 'radiation:observations:v1',
Expand Down Expand Up @@ -82,6 +83,7 @@ const BOOTSTRAP_CACHE_KEYS = {
shippingStress: 'supply_chain:shipping_stress:v1',
socialVelocity: 'intelligence:social:reddit:v1',
diseaseOutbreaks: 'health:disease-outbreaks:v1',
healthAirQuality: 'health:air-quality:v1',
economicStress: 'economic:stress-index:v1',
};

Expand Down Expand Up @@ -117,7 +119,7 @@ const FAST_KEYS = new Set([
'earthquakes', 'outages', 'serviceStatuses', 'ddosAttacks', 'trafficAnomalies', 'macroSignals', 'chokepoints', 'chokepointTransits',
'marketQuotes', 'commodityQuotes', 'positiveGeoEvents', 'riskScores', 'flightDelays','insights', 'predictions',
'iranEvents', 'temporalAnomalies', 'weatherAlerts', 'spending', 'theaterPosture', 'gdeltIntel',
'correlationCards', 'forecasts', 'shippingRates', 'shippingStress', 'socialVelocity',
'correlationCards', 'forecasts', 'shippingRates', 'shippingStress', 'socialVelocity', 'climateAirQuality', 'healthAirQuality',
]);

// No public/s-maxage: CF (in front of api.worldmonitor.app) ignores Vary: Origin and would
Expand Down Expand Up @@ -157,6 +159,62 @@ async function getCachedJsonBatch(keys) {
return result;
}

function asString(value) {
return typeof value === 'string' ? value.trim() : '';
}

function asNumber(value) {
const numeric = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numeric) ? numeric : null;
}

function normalizeAirQualityStation(station) {
if (!station || typeof station !== 'object') return null;
const city = asString(station.city);
const lat = asNumber(station.lat);
const lng = asNumber(station.lng);
const pm25 = asNumber(station.pm25);
const aqi = asNumber(station.aqi);
const measuredAt = asNumber(station.measured_at ?? station.measuredAt);
if (!city || lat == null || lng == null || pm25 == null || aqi == null || measuredAt == null) return null;

return {
city,
countryCode: asString(station.country_code ?? station.countryCode),
lat,
lng,
pm25,
aqi: Math.max(0, Math.min(500, Math.round(aqi))),
riskLevel: asString(station.risk_level ?? station.riskLevel),
pollutant: asString(station.pollutant) || 'pm25',
measuredAt: Math.round(measuredAt),
source: asString(station.source),
};
}

function normalizeAirQualityPayload(payload, collectionField = 'stations') {
if (!payload || typeof payload !== 'object') {
return collectionField === 'alerts' ? { alerts: [], fetchedAt: 0 } : { stations: [], fetchedAt: 0 };
}

const rows = Array.isArray(payload.stations)
? payload.stations
: Array.isArray(payload.alerts)
? payload.alerts
: [];

const stations = rows
.map((row) => normalizeAirQualityStation(row))
.filter(Boolean);

const fetchedAtRaw = payload.fetched_at ?? payload.fetchedAt;
const fetchedAt = asNumber(fetchedAtRaw);
if (collectionField === 'alerts') {
return { alerts: stations, fetchedAt: fetchedAt == null ? 0 : Math.round(fetchedAt) };
}
return { stations, fetchedAt: fetchedAt == null ? 0 : Math.round(fetchedAt) };
}

export default async function handler(req) {
if (isDisallowedOrigin(req))
return new Response('Forbidden', { status: 403 });
Expand Down Expand Up @@ -201,6 +259,10 @@ export default async function handler(req) {
if (names[i] === 'forecasts' && val != null && 'enrichmentMeta' in val) {
const { enrichmentMeta: _stripped, ...rest } = val;
data[names[i]] = rest;
} else if (names[i] === 'climateAirQuality') {
data[names[i]] = normalizeAirQualityPayload(val, 'stations');
} else if (names[i] === 'healthAirQuality') {
data[names[i]] = normalizeAirQualityPayload(val, 'alerts');
} else {
data[names[i]] = val;
}
Expand Down
4 changes: 4 additions & 0 deletions api/health.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const BOOTSTRAP_KEYS = {
sectors: 'market:sectors:v1',
etfFlows: 'market:etf-flows:v1',
climateAnomalies: 'climate:anomalies:v2',
climateAirQuality: 'climate:air-quality:v1',
co2Monitoring: 'climate:co2-monitoring:v1',
wildfires: 'wildfire:fires:v1',
marketQuotes: 'market:stocks-bootstrap:v1',
Expand Down Expand Up @@ -74,6 +75,7 @@ const BOOTSTRAP_KEYS = {
euFsi: 'economic:fsi-eu:v1',
shippingStress: 'supply_chain:shipping_stress:v1',
diseaseOutbreaks: 'health:disease-outbreaks:v1',
healthAirQuality: 'health:air-quality:v1',
socialVelocity: 'intelligence:social:reddit:v1',
vpdTrackerRealtime: 'health:vpd-tracker:realtime:v1',
vpdTrackerHistorical: 'health:vpd-tracker:historical:v1',
Expand Down Expand Up @@ -130,6 +132,7 @@ 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: 240 }, // runs as independent Railway cron (0 */2 * * *); 240 = 2x interval
climateAirQuality:{ key: 'seed-meta:climate:air-quality', maxStaleMin: 180 }, // hourly cron; 180 = 3x interval for shared health/climate seed
climateZoneNormals: { key: 'seed-meta:climate:zone-normals', maxStaleMin: 89280 }, // monthly cron on the 1st; 62d = 2x 31-day cadence
co2Monitoring: { key: 'seed-meta:climate:co2-monitoring', maxStaleMin: 4320 }, // daily cron at 06:00 UTC; 72h tolerates two missed runs
climateNews: { key: 'seed-meta:climate:news-intelligence', maxStaleMin: 90 }, // relay loop every 30min; 90 = 3× interval
Expand Down Expand Up @@ -225,6 +228,7 @@ const SEED_META = {
newsThreatSummary: { key: 'seed-meta:news:threat-summary', maxStaleMin: 60 }, // relay classify every ~20min; 60min = 3x interval
shippingStress: { key: 'seed-meta:supply_chain:shipping_stress', maxStaleMin: 45 }, // relay loop every 15min; 45 = 3x interval (was 30 = 2×, too tight on relay hiccup)
diseaseOutbreaks: { key: 'seed-meta:health:disease-outbreaks', maxStaleMin: 2880 }, // daily seed; 2880 = 48h = 2x interval
healthAirQuality: { key: 'seed-meta:health:air-quality', maxStaleMin: 180 }, // hourly cron; 180 = 3x interval for shared health/climate seed
socialVelocity: { key: 'seed-meta:intelligence:social-reddit', maxStaleMin: 30 }, // relay loop every 10min; 30 = 3x interval (was 20 = equals retry window, too tight)
vpdTrackerRealtime: { key: 'seed-meta:health:vpd-tracker', maxStaleMin: 2880 }, // daily seed (0 2 * * *); 2880min = 48h = 2x interval
vpdTrackerHistorical: { key: 'seed-meta:health:vpd-tracker', maxStaleMin: 2880 }, // shares seed-meta key with vpdTrackerRealtime (same run)
Expand Down
2 changes: 1 addition & 1 deletion docs/api/ClimateService.openapi.json

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions docs/api/ClimateService.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,32 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/climate/v1/list-air-quality-data:
get:
tags:
- ClimateService
summary: ListAirQualityData
description: ListAirQualityData retrieves recent PM2.5 station data from the shared air-quality seed.
operationId: ListAirQualityData
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ListAirQualityDataResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/climate/v1/list-climate-news:
get:
tags:
Expand Down Expand Up @@ -293,6 +319,48 @@ components:
type: number
format: double
description: Year-over-year delta vs same calendar month, in ppm.
ListAirQualityDataRequest:
type: object
ListAirQualityDataResponse:
type: object
properties:
stations:
type: array
items:
$ref: '#/components/schemas/AirQualityStation'
fetchedAt:
type: integer
format: int64
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
AirQualityStation:
type: object
properties:
city:
type: string
countryCode:
type: string
lat:
type: number
format: double
lng:
type: number
format: double
pm25:
type: number
format: double
aqi:
type: integer
format: int32
riskLevel:
type: string
pollutant:
type: string
measuredAt:
type: integer
format: int64
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
source:
type: string
ListClimateNewsRequest:
type: object
ListClimateNewsResponse:
Expand Down
2 changes: 1 addition & 1 deletion docs/api/HealthService.openapi.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"components":{"schemas":{"DiseaseOutbreakItem":{"description":"DiseaseOutbreakItem represents a single disease outbreak event.","properties":{"alertLevel":{"description":"Alert level: \"watch\" | \"warning\" | \"alert\".","type":"string"},"cases":{"description":"Case count if reported by source (0 = unknown).","format":"int32","type":"integer"},"countryCode":{"description":"ISO2 country code when known.","type":"string"},"disease":{"description":"Disease or outbreak name.","type":"string"},"id":{"description":"Unique identifier (URL-derived).","type":"string"},"lat":{"description":"Precise latitude from source (overrides country centroid on map when non-zero).","format":"double","type":"number"},"lng":{"description":"Precise longitude from source (overrides country centroid on map when non-zero).","format":"double","type":"number"},"location":{"description":"Affected country or region.","type":"string"},"publishedAt":{"description":"Unix epoch milliseconds when published.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"sourceName":{"description":"Source name (e.g., \"WHO\", \"ProMED\", \"HealthMap\").","type":"string"},"sourceUrl":{"description":"Source URL.","type":"string"},"summary":{"description":"Short description from the source.","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"},"ListDiseaseOutbreaksRequest":{"type":"object"},"ListDiseaseOutbreaksResponse":{"properties":{"fetchedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"outbreaks":{"items":{"$ref":"#/components/schemas/DiseaseOutbreakItem"},"type":"array"}},"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":"HealthService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/health/v1/list-disease-outbreaks":{"get":{"description":"ListDiseaseOutbreaks returns recent WHO/ProMED disease outbreak alerts.","operationId":"ListDiseaseOutbreaks","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDiseaseOutbreaksResponse"}}},"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":"ListDiseaseOutbreaks","tags":["HealthService"]}}}}
{"components":{"schemas":{"AirQualityAlert":{"properties":{"aqi":{"format":"int32","type":"integer"},"city":{"type":"string"},"countryCode":{"type":"string"},"lat":{"format":"double","type":"number"},"lng":{"format":"double","type":"number"},"measuredAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"pm25":{"format":"double","type":"number"},"pollutant":{"type":"string"},"riskLevel":{"type":"string"},"source":{"type":"string"}},"type":"object"},"DiseaseOutbreakItem":{"description":"DiseaseOutbreakItem represents a single disease outbreak event.","properties":{"alertLevel":{"description":"Alert level: \"watch\" | \"warning\" | \"alert\".","type":"string"},"cases":{"description":"Case count if reported by source (0 = unknown).","format":"int32","type":"integer"},"countryCode":{"description":"ISO2 country code when known.","type":"string"},"disease":{"description":"Disease or outbreak name.","type":"string"},"id":{"description":"Unique identifier (URL-derived).","type":"string"},"lat":{"description":"Precise latitude from source (overrides country centroid on map when non-zero).","format":"double","type":"number"},"lng":{"description":"Precise longitude from source (overrides country centroid on map when non-zero).","format":"double","type":"number"},"location":{"description":"Affected country or region.","type":"string"},"publishedAt":{"description":"Unix epoch milliseconds when published.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"sourceName":{"description":"Source name (e.g., \"WHO\", \"ProMED\", \"HealthMap\").","type":"string"},"sourceUrl":{"description":"Source URL.","type":"string"},"summary":{"description":"Short description from the source.","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"},"ListAirQualityAlertsRequest":{"type":"object"},"ListAirQualityAlertsResponse":{"properties":{"alerts":{"items":{"$ref":"#/components/schemas/AirQualityAlert"},"type":"array"},"fetchedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"type":"object"},"ListDiseaseOutbreaksRequest":{"type":"object"},"ListDiseaseOutbreaksResponse":{"properties":{"fetchedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"outbreaks":{"items":{"$ref":"#/components/schemas/DiseaseOutbreakItem"},"type":"array"}},"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":"HealthService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/health/v1/list-air-quality-alerts":{"get":{"description":"ListAirQualityAlerts returns recent PM2.5 stations with AQI-derived health risk.","operationId":"ListAirQualityAlerts","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListAirQualityAlertsResponse"}}},"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":"ListAirQualityAlerts","tags":["HealthService"]}},"/api/health/v1/list-disease-outbreaks":{"get":{"description":"ListDiseaseOutbreaks returns recent WHO/ProMED disease outbreak alerts.","operationId":"ListDiseaseOutbreaks","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDiseaseOutbreaksResponse"}}},"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":"ListDiseaseOutbreaks","tags":["HealthService"]}}}}
Loading
Loading