mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat: move EONET/GDACS to server-side with Redis caching (#983)
* feat: move EONET/GDACS to server-side with Redis caching and bootstrap hydration Browser-direct fetches to eonet.gsfc.nasa.gov and gdacs.org caused CORS errors and had no server-side caching. This moves both to the standard Vercel edge → cachedFetchJson → Redis → bootstrap hydration pattern. - Add proto definitions for NaturalService with ListNaturalEvents RPC - Create server handler merging EONET + GDACS with 30min Redis TTL - Add Vercel edge function at /api/natural/v1/list-natural-events - Register naturalEvents in bootstrap SLOW_KEYS for CDN hydration - Replace browser-direct fetches with RPC client + circuit breaker - Delete src/services/gdacs.ts (logic moved server-side) * fix: restore @ts-nocheck on generated files stripped by buf generate
This commit is contained in:
3
api/bootstrap.js
vendored
3
api/bootstrap.js
vendored
@@ -26,12 +26,13 @@ const BOOTSTRAP_CACHE_KEYS = {
|
||||
positiveGeoEvents: 'positive-events:geo-bootstrap:v1',
|
||||
theaterPosture: 'theater-posture:sebuf:stale:v1',
|
||||
riskScores: 'risk:scores:sebuf:stale:v1',
|
||||
naturalEvents: 'natural:events:v1',
|
||||
};
|
||||
|
||||
const SLOW_KEYS = new Set([
|
||||
'bisPolicy', 'bisExchange', 'bisCredit', 'minerals', 'giving',
|
||||
'sectors', 'etfFlows', 'shippingRates', 'wildfires', 'climateAnomalies',
|
||||
'cyberThreats', 'techReadiness', 'theaterPosture', 'riskScores',
|
||||
'cyberThreats', 'techReadiness', 'theaterPosture', 'riskScores', 'naturalEvents',
|
||||
]);
|
||||
const FAST_KEYS = new Set([
|
||||
'earthquakes', 'outages', 'serviceStatuses', 'macroSignals', 'chokepoints',
|
||||
|
||||
9
api/natural/v1/[rpc].ts
Normal file
9
api/natural/v1/[rpc].ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const config = { runtime: 'edge' };
|
||||
|
||||
import { createDomainGateway, serverOptions } from '../../../server/gateway';
|
||||
import { createNaturalServiceRoutes } from '../../../src/generated/server/worldmonitor/natural/v1/service_server';
|
||||
import { naturalHandler } from '../../../server/worldmonitor/natural/v1/handler';
|
||||
|
||||
export default createDomainGateway(
|
||||
createNaturalServiceRoutes(naturalHandler, serverOptions),
|
||||
);
|
||||
1
docs/api/NaturalService.openapi.json
Normal file
1
docs/api/NaturalService.openapi.json
Normal file
@@ -0,0 +1 @@
|
||||
{"components":{"schemas":{"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"},"ListNaturalEventsRequest":{"properties":{"days":{"format":"int32","type":"integer"}},"type":"object"},"ListNaturalEventsResponse":{"properties":{"events":{"items":{"$ref":"#/components/schemas/NaturalEvent"},"type":"array"}},"type":"object"},"NaturalEvent":{"properties":{"category":{"type":"string"},"categoryTitle":{"type":"string"},"closed":{"type":"boolean"},"date":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"description":{"type":"string"},"id":{"type":"string"},"lat":{"format":"double","type":"number"},"lon":{"format":"double","type":"number"},"magnitude":{"format":"double","type":"number"},"magnitudeUnit":{"type":"string"},"sourceName":{"type":"string"},"sourceUrl":{"type":"string"},"title":{"type":"string"}},"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":"NaturalService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/natural/v1/list-natural-events":{"get":{"operationId":"ListNaturalEvents","parameters":[{"in":"query","name":"days","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListNaturalEventsResponse"}}},"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":"ListNaturalEvents","tags":["NaturalService"]}}}}
|
||||
117
docs/api/NaturalService.openapi.yaml
Normal file
117
docs/api/NaturalService.openapi.yaml
Normal file
@@ -0,0 +1,117 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: NaturalService API
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/natural/v1/list-natural-events:
|
||||
get:
|
||||
tags:
|
||||
- NaturalService
|
||||
summary: ListNaturalEvents
|
||||
operationId: ListNaturalEvents
|
||||
parameters:
|
||||
- name: days
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ListNaturalEventsResponse'
|
||||
"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:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
description: Error message (e.g., 'user not found', 'database connection failed')
|
||||
description: Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.
|
||||
FieldViolation:
|
||||
type: object
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
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')
|
||||
description:
|
||||
type: string
|
||||
description: Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')
|
||||
required:
|
||||
- field
|
||||
- description
|
||||
description: FieldViolation describes a single validation error for a specific field.
|
||||
ValidationError:
|
||||
type: object
|
||||
properties:
|
||||
violations:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/FieldViolation'
|
||||
description: List of validation violations
|
||||
required:
|
||||
- violations
|
||||
description: ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.
|
||||
ListNaturalEventsRequest:
|
||||
type: object
|
||||
properties:
|
||||
days:
|
||||
type: integer
|
||||
format: int32
|
||||
ListNaturalEventsResponse:
|
||||
type: object
|
||||
properties:
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/NaturalEvent'
|
||||
NaturalEvent:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
categoryTitle:
|
||||
type: string
|
||||
lat:
|
||||
type: number
|
||||
format: double
|
||||
lon:
|
||||
type: number
|
||||
format: double
|
||||
date:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
magnitude:
|
||||
type: number
|
||||
format: double
|
||||
magnitudeUnit:
|
||||
type: string
|
||||
sourceUrl:
|
||||
type: string
|
||||
sourceName:
|
||||
type: string
|
||||
closed:
|
||||
type: boolean
|
||||
File diff suppressed because one or more lines are too long
@@ -169,15 +169,6 @@ components:
|
||||
required:
|
||||
- provider
|
||||
description: SummarizeArticleRequest specifies parameters for LLM article summarization.
|
||||
SummarizeStatus:
|
||||
type: string
|
||||
enum:
|
||||
- SUMMARIZE_STATUS_UNSPECIFIED
|
||||
- SUMMARIZE_STATUS_SUCCESS
|
||||
- SUMMARIZE_STATUS_CACHED
|
||||
- SUMMARIZE_STATUS_SKIPPED
|
||||
- SUMMARIZE_STATUS_ERROR
|
||||
description: SummarizeStatus indicates the outcome of a summarization request.
|
||||
SummarizeArticleResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -204,10 +195,17 @@ components:
|
||||
type: string
|
||||
description: Error type/name (e.g. "TypeError").
|
||||
status:
|
||||
$ref: '#/components/schemas/SummarizeStatus'
|
||||
type: string
|
||||
enum:
|
||||
- SUMMARIZE_STATUS_UNSPECIFIED
|
||||
- SUMMARIZE_STATUS_SUCCESS
|
||||
- SUMMARIZE_STATUS_CACHED
|
||||
- SUMMARIZE_STATUS_SKIPPED
|
||||
- SUMMARIZE_STATUS_ERROR
|
||||
description: SummarizeStatus indicates the outcome of a summarization request.
|
||||
statusDetail:
|
||||
type: string
|
||||
description: Human-readable detail for non-success statuses (error message, skip reason, etc.).
|
||||
description: Human-readable detail for non-success statuses (skip reason, etc.).
|
||||
description: SummarizeArticleResponse contains the LLM summarization result.
|
||||
GetSummarizeArticleCacheRequest:
|
||||
type: object
|
||||
|
||||
29
proto/worldmonitor/natural/v1/list_natural_events.proto
Normal file
29
proto/worldmonitor/natural/v1/list_natural_events.proto
Normal file
@@ -0,0 +1,29 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.natural.v1;
|
||||
|
||||
import "sebuf/http/annotations.proto";
|
||||
|
||||
message NaturalEvent {
|
||||
string id = 1;
|
||||
string title = 2;
|
||||
string description = 3;
|
||||
string category = 4;
|
||||
string category_title = 5;
|
||||
double lat = 6;
|
||||
double lon = 7;
|
||||
int64 date = 8 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
double magnitude = 9;
|
||||
string magnitude_unit = 10;
|
||||
string source_url = 11;
|
||||
string source_name = 12;
|
||||
bool closed = 13;
|
||||
}
|
||||
|
||||
message ListNaturalEventsRequest {
|
||||
int32 days = 1 [(sebuf.http.query) = { name: "days" }];
|
||||
}
|
||||
|
||||
message ListNaturalEventsResponse {
|
||||
repeated NaturalEvent events = 1;
|
||||
}
|
||||
14
proto/worldmonitor/natural/v1/service.proto
Normal file
14
proto/worldmonitor/natural/v1/service.proto
Normal file
@@ -0,0 +1,14 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.natural.v1;
|
||||
|
||||
import "sebuf/http/annotations.proto";
|
||||
import "worldmonitor/natural/v1/list_natural_events.proto";
|
||||
|
||||
service NaturalService {
|
||||
option (sebuf.http.service_config) = {base_path: "/api/natural/v1"};
|
||||
|
||||
rpc ListNaturalEvents(ListNaturalEventsRequest) returns (ListNaturalEventsResponse) {
|
||||
option (sebuf.http.config) = {path: "/list-natural-events", method: HTTP_METHOD_GET};
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||||
'/api/aviation/v1/list-airport-delays': 'static',
|
||||
'/api/market/v1/get-country-stock-index': 'slow',
|
||||
|
||||
'/api/natural/v1/list-natural-events': 'slow',
|
||||
'/api/wildfire/v1/list-fire-detections': 'static',
|
||||
'/api/maritime/v1/list-navigational-warnings': 'static',
|
||||
'/api/supply-chain/v1/get-shipping-rates': 'static',
|
||||
|
||||
7
server/worldmonitor/natural/v1/handler.ts
Normal file
7
server/worldmonitor/natural/v1/handler.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NaturalServiceHandler } from '../../../../src/generated/server/worldmonitor/natural/v1/service_server';
|
||||
|
||||
import { listNaturalEvents } from './list-natural-events';
|
||||
|
||||
export const naturalHandler: NaturalServiceHandler = {
|
||||
listNaturalEvents,
|
||||
};
|
||||
177
server/worldmonitor/natural/v1/list-natural-events.ts
Normal file
177
server/worldmonitor/natural/v1/list-natural-events.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type {
|
||||
NaturalServiceHandler,
|
||||
ServerContext,
|
||||
ListNaturalEventsRequest,
|
||||
ListNaturalEventsResponse,
|
||||
NaturalEvent,
|
||||
} from '../../../../src/generated/server/worldmonitor/natural/v1/service_server';
|
||||
|
||||
import { CHROME_UA } from '../../../_shared/constants';
|
||||
import { cachedFetchJson } from '../../../_shared/redis';
|
||||
|
||||
const REDIS_CACHE_KEY = 'natural:events:v1';
|
||||
const REDIS_CACHE_TTL = 1800; // 30 min
|
||||
|
||||
const EONET_API_URL = 'https://eonet.gsfc.nasa.gov/api/v3/events';
|
||||
const GDACS_API = 'https://www.gdacs.org/gdacsapi/api/events/geteventlist/MAP';
|
||||
|
||||
const DAYS = 30;
|
||||
const WILDFIRE_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
||||
|
||||
const GDACS_TO_CATEGORY: Record<string, string> = {
|
||||
EQ: 'earthquakes',
|
||||
FL: 'floods',
|
||||
TC: 'severeStorms',
|
||||
VO: 'volcanoes',
|
||||
WF: 'wildfires',
|
||||
DR: 'drought',
|
||||
};
|
||||
|
||||
const EVENT_TYPE_NAMES: Record<string, string> = {
|
||||
EQ: 'Earthquake',
|
||||
FL: 'Flood',
|
||||
TC: 'Tropical Cyclone',
|
||||
VO: 'Volcano',
|
||||
WF: 'Wildfire',
|
||||
DR: 'Drought',
|
||||
};
|
||||
|
||||
async function fetchEonet(days: number): Promise<NaturalEvent[]> {
|
||||
const url = `${EONET_API_URL}?status=open&days=${days}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: 'application/json', 'User-Agent': CHROME_UA },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`EONET ${res.status}`);
|
||||
|
||||
const data: any = await res.json();
|
||||
const events: NaturalEvent[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const event of data.events || []) {
|
||||
const category = event.categories?.[0];
|
||||
if (!category) continue;
|
||||
if (category.id === 'earthquakes') continue;
|
||||
|
||||
const latestGeo = event.geometry?.[event.geometry.length - 1];
|
||||
if (!latestGeo || latestGeo.type !== 'Point') continue;
|
||||
|
||||
const eventDate = new Date(latestGeo.date);
|
||||
const [lon, lat] = latestGeo.coordinates;
|
||||
|
||||
if (category.id === 'wildfires' && now - eventDate.getTime() > WILDFIRE_MAX_AGE_MS) continue;
|
||||
|
||||
const source = event.sources?.[0];
|
||||
events.push({
|
||||
id: event.id || '',
|
||||
title: event.title || '',
|
||||
description: event.description || '',
|
||||
category: category.id || '',
|
||||
categoryTitle: category.title || '',
|
||||
lat,
|
||||
lon,
|
||||
date: eventDate.getTime(),
|
||||
magnitude: latestGeo.magnitudeValue ?? 0,
|
||||
magnitudeUnit: latestGeo.magnitudeUnit || '',
|
||||
sourceUrl: source?.url || '',
|
||||
sourceName: source?.id || '',
|
||||
closed: event.closed !== null,
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
async function fetchGdacs(): Promise<NaturalEvent[]> {
|
||||
const res = await fetch(GDACS_API, {
|
||||
headers: { Accept: 'application/json', 'User-Agent': CHROME_UA },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`GDACS ${res.status}`);
|
||||
|
||||
const data: any = await res.json();
|
||||
const features: any[] = data.features || [];
|
||||
const seen = new Set<string>();
|
||||
const events: NaturalEvent[] = [];
|
||||
|
||||
for (const f of features) {
|
||||
if (!f.geometry || f.geometry.type !== 'Point') continue;
|
||||
const props = f.properties;
|
||||
const key = `${props.eventtype}-${props.eventid}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
if (props.alertlevel === 'Green') continue;
|
||||
|
||||
const category = GDACS_TO_CATEGORY[props.eventtype] || 'manmade';
|
||||
const alertPrefix = props.alertlevel === 'Red' ? '🔴 ' : props.alertlevel === 'Orange' ? '🟠 ' : '';
|
||||
const description = props.description || EVENT_TYPE_NAMES[props.eventtype] || props.eventtype;
|
||||
const severity = props.severitydata?.severitytext || '';
|
||||
|
||||
events.push({
|
||||
id: `gdacs-${props.eventtype}-${props.eventid}`,
|
||||
title: `${alertPrefix}${props.name || ''}`,
|
||||
description: `${description}${severity ? ` - ${severity}` : ''}`,
|
||||
category,
|
||||
categoryTitle: description,
|
||||
lat: f.geometry.coordinates[1] ?? 0,
|
||||
lon: f.geometry.coordinates[0] ?? 0,
|
||||
date: new Date(props.fromdate || 0).getTime(),
|
||||
magnitude: 0,
|
||||
magnitudeUnit: '',
|
||||
sourceUrl: props.url?.report || '',
|
||||
sourceName: 'GDACS',
|
||||
closed: false,
|
||||
});
|
||||
}
|
||||
|
||||
return events.slice(0, 100);
|
||||
}
|
||||
|
||||
export const listNaturalEvents: NaturalServiceHandler['listNaturalEvents'] = async (
|
||||
_ctx: ServerContext,
|
||||
_req: ListNaturalEventsRequest,
|
||||
): Promise<ListNaturalEventsResponse> => {
|
||||
|
||||
try {
|
||||
const result = await cachedFetchJson<ListNaturalEventsResponse>(
|
||||
REDIS_CACHE_KEY,
|
||||
REDIS_CACHE_TTL,
|
||||
async () => {
|
||||
const [eonetResult, gdacsResult] = await Promise.allSettled([
|
||||
fetchEonet(DAYS),
|
||||
fetchGdacs(),
|
||||
]);
|
||||
|
||||
const eonetEvents = eonetResult.status === 'fulfilled' ? eonetResult.value : [];
|
||||
const gdacsEvents = gdacsResult.status === 'fulfilled' ? gdacsResult.value : [];
|
||||
|
||||
if (eonetResult.status === 'rejected') console.error('[EONET]', eonetResult.reason?.message);
|
||||
if (gdacsResult.status === 'rejected') console.error('[GDACS]', gdacsResult.reason?.message);
|
||||
|
||||
const seenLocations = new Set<string>();
|
||||
const merged: NaturalEvent[] = [];
|
||||
|
||||
for (const event of gdacsEvents) {
|
||||
const k = `${event.lat.toFixed(1)}-${event.lon.toFixed(1)}-${event.category}`;
|
||||
if (!seenLocations.has(k)) {
|
||||
seenLocations.add(k);
|
||||
merged.push(event);
|
||||
}
|
||||
}
|
||||
for (const event of eonetEvents) {
|
||||
const k = `${event.lat.toFixed(1)}-${event.lon.toFixed(1)}-${event.category}`;
|
||||
if (!seenLocations.has(k)) {
|
||||
seenLocations.add(k);
|
||||
merged.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return merged.length > 0 ? { events: merged } : null;
|
||||
},
|
||||
);
|
||||
return result || { events: [] };
|
||||
} catch {
|
||||
return { events: [] };
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
|
||||
// source: worldmonitor/displacement/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
|
||||
// source: worldmonitor/giving/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
|
||||
// source: worldmonitor/intelligence/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
|
||||
// source: worldmonitor/maritime/v1/service.proto
|
||||
|
||||
|
||||
117
src/generated/client/worldmonitor/natural/v1/service_client.ts
Normal file
117
src/generated/client/worldmonitor/natural/v1/service_client.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
|
||||
// source: worldmonitor/natural/v1/service.proto
|
||||
|
||||
export interface ListNaturalEventsRequest {
|
||||
days: number;
|
||||
}
|
||||
|
||||
export interface ListNaturalEventsResponse {
|
||||
events: NaturalEvent[];
|
||||
}
|
||||
|
||||
export interface NaturalEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
categoryTitle: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
date: number;
|
||||
magnitude: number;
|
||||
magnitudeUnit: string;
|
||||
sourceUrl: string;
|
||||
sourceName: string;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
field: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {
|
||||
violations: FieldViolation[];
|
||||
|
||||
constructor(violations: FieldViolation[]) {
|
||||
super("Validation failed");
|
||||
this.name = "ValidationError";
|
||||
this.violations = violations;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
statusCode: number;
|
||||
body: string;
|
||||
|
||||
constructor(statusCode: number, message: string, body: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NaturalServiceClientOptions {
|
||||
fetch?: typeof fetch;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NaturalServiceCallOptions {
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class NaturalServiceClient {
|
||||
private baseURL: string;
|
||||
private fetchFn: typeof fetch;
|
||||
private defaultHeaders: Record<string, string>;
|
||||
|
||||
constructor(baseURL: string, options?: NaturalServiceClientOptions) {
|
||||
this.baseURL = baseURL.replace(/\/+$/, "");
|
||||
this.fetchFn = options?.fetch ?? globalThis.fetch;
|
||||
this.defaultHeaders = { ...options?.defaultHeaders };
|
||||
}
|
||||
|
||||
async listNaturalEvents(req: ListNaturalEventsRequest, options?: NaturalServiceCallOptions): Promise<ListNaturalEventsResponse> {
|
||||
let path = "/api/natural/v1/list-natural-events";
|
||||
const params = new URLSearchParams();
|
||||
if (req.days != null && req.days !== 0) params.set("days", String(req.days));
|
||||
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"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 ListNaturalEventsResponse;
|
||||
}
|
||||
|
||||
private async handleError(resp: Response): Promise<never> {
|
||||
const body = await resp.text();
|
||||
if (resp.status === 400) {
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
if (parsed.violations) {
|
||||
throw new ValidationError(parsed.violations);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationError) throw e;
|
||||
}
|
||||
}
|
||||
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
|
||||
// source: worldmonitor/news/v1/service.proto
|
||||
|
||||
@@ -10,8 +11,6 @@ export interface SummarizeArticleRequest {
|
||||
lang: string;
|
||||
}
|
||||
|
||||
export type SummarizeStatus = "SUMMARIZE_STATUS_UNSPECIFIED" | "SUMMARIZE_STATUS_SUCCESS" | "SUMMARIZE_STATUS_CACHED" | "SUMMARIZE_STATUS_SKIPPED" | "SUMMARIZE_STATUS_ERROR";
|
||||
|
||||
export interface SummarizeArticleResponse {
|
||||
summary: string;
|
||||
model: string;
|
||||
@@ -66,6 +65,8 @@ export interface GeoCoordinates {
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export type SummarizeStatus = "SUMMARIZE_STATUS_UNSPECIFIED" | "SUMMARIZE_STATUS_SUCCESS" | "SUMMARIZE_STATUS_CACHED" | "SUMMARIZE_STATUS_SKIPPED" | "SUMMARIZE_STATUS_ERROR";
|
||||
|
||||
export type ThreatLevel = "THREAT_LEVEL_UNSPECIFIED" | "THREAT_LEVEL_LOW" | "THREAT_LEVEL_MEDIUM" | "THREAT_LEVEL_HIGH" | "THREAT_LEVEL_CRITICAL";
|
||||
|
||||
export interface FieldViolation {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
|
||||
// source: worldmonitor/prediction/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
|
||||
// source: worldmonitor/research/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
|
||||
// source: worldmonitor/seismology/v1/service.proto
|
||||
|
||||
|
||||
@@ -207,3 +207,4 @@ export class SupplyChainServiceClient {
|
||||
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
|
||||
// source: worldmonitor/wildfire/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/conflict/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/displacement/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/giving/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/intelligence/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/maritime/v1/service.proto
|
||||
|
||||
|
||||
131
src/generated/server/worldmonitor/natural/v1/service_server.ts
Normal file
131
src/generated/server/worldmonitor/natural/v1/service_server.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/natural/v1/service.proto
|
||||
|
||||
export interface ListNaturalEventsRequest {
|
||||
days: number;
|
||||
}
|
||||
|
||||
export interface ListNaturalEventsResponse {
|
||||
events: NaturalEvent[];
|
||||
}
|
||||
|
||||
export interface NaturalEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
categoryTitle: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
date: number;
|
||||
magnitude: number;
|
||||
magnitudeUnit: string;
|
||||
sourceUrl: string;
|
||||
sourceName: string;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
field: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {
|
||||
violations: FieldViolation[];
|
||||
|
||||
constructor(violations: FieldViolation[]) {
|
||||
super("Validation failed");
|
||||
this.name = "ValidationError";
|
||||
this.violations = violations;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
statusCode: number;
|
||||
body: string;
|
||||
|
||||
constructor(statusCode: number, message: string, body: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServerContext {
|
||||
request: Request;
|
||||
pathParams: Record<string, string>;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ServerOptions {
|
||||
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
|
||||
validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined;
|
||||
}
|
||||
|
||||
export interface RouteDescriptor {
|
||||
method: string;
|
||||
path: string;
|
||||
handler: (req: Request) => Promise<Response>;
|
||||
}
|
||||
|
||||
export interface NaturalServiceHandler {
|
||||
listNaturalEvents(ctx: ServerContext, req: ListNaturalEventsRequest): Promise<ListNaturalEventsResponse>;
|
||||
}
|
||||
|
||||
export function createNaturalServiceRoutes(
|
||||
handler: NaturalServiceHandler,
|
||||
options?: ServerOptions,
|
||||
): RouteDescriptor[] {
|
||||
return [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/natural/v1/list-natural-events",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const params = url.searchParams;
|
||||
const body: ListNaturalEventsRequest = {
|
||||
days: Number(params.get("days") ?? "0"),
|
||||
};
|
||||
if (options?.validateRequest) {
|
||||
const bodyViolations = options.validateRequest("listNaturalEvents", body);
|
||||
if (bodyViolations) {
|
||||
throw new ValidationError(bodyViolations);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.listNaturalEvents(ctx, body);
|
||||
return new Response(JSON.stringify(result as ListNaturalEventsResponse), {
|
||||
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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/news/v1/service.proto
|
||||
|
||||
@@ -10,8 +11,6 @@ export interface SummarizeArticleRequest {
|
||||
lang: string;
|
||||
}
|
||||
|
||||
export type SummarizeStatus = "SUMMARIZE_STATUS_UNSPECIFIED" | "SUMMARIZE_STATUS_SUCCESS" | "SUMMARIZE_STATUS_CACHED" | "SUMMARIZE_STATUS_SKIPPED" | "SUMMARIZE_STATUS_ERROR";
|
||||
|
||||
export interface SummarizeArticleResponse {
|
||||
summary: string;
|
||||
model: string;
|
||||
@@ -66,6 +65,8 @@ export interface GeoCoordinates {
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export type SummarizeStatus = "SUMMARIZE_STATUS_UNSPECIFIED" | "SUMMARIZE_STATUS_SUCCESS" | "SUMMARIZE_STATUS_CACHED" | "SUMMARIZE_STATUS_SKIPPED" | "SUMMARIZE_STATUS_ERROR";
|
||||
|
||||
export type ThreatLevel = "THREAT_LEVEL_UNSPECIFIED" | "THREAT_LEVEL_LOW" | "THREAT_LEVEL_MEDIUM" | "THREAT_LEVEL_HIGH" | "THREAT_LEVEL_CRITICAL";
|
||||
|
||||
export interface FieldViolation {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/positive_events/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/prediction/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/research/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/seismology/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/supply_chain/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/wildfire/v1/service.proto
|
||||
|
||||
|
||||
@@ -1,40 +1,10 @@
|
||||
import type { NaturalEvent, NaturalEventCategory } from '@/types';
|
||||
import { fetchGDACSEvents, type GDACSEvent } from './gdacs';
|
||||
|
||||
interface EonetGeometry {
|
||||
magnitudeValue?: number;
|
||||
magnitudeUnit?: string;
|
||||
date: string;
|
||||
type: string;
|
||||
coordinates: [number, number];
|
||||
}
|
||||
|
||||
interface EonetSource {
|
||||
id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface EonetCategory {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface EonetEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
closed: string | null;
|
||||
categories: EonetCategory[];
|
||||
sources: EonetSource[];
|
||||
geometry: EonetGeometry[];
|
||||
}
|
||||
|
||||
interface EonetResponse {
|
||||
title: string;
|
||||
events: EonetEvent[];
|
||||
}
|
||||
|
||||
const EONET_API_URL = 'https://eonet.gsfc.nasa.gov/api/v3/events';
|
||||
import {
|
||||
NaturalServiceClient,
|
||||
type ListNaturalEventsResponse,
|
||||
} from '@/generated/client/worldmonitor/natural/v1/service_client';
|
||||
import { createCircuitBreaker } from '@/utils';
|
||||
import { getHydratedData } from '@/services/bootstrap';
|
||||
|
||||
const CATEGORY_ICONS: Record<NaturalEventCategory, string> = {
|
||||
severeStorms: '🌀',
|
||||
@@ -56,117 +26,34 @@ export function getNaturalEventIcon(category: NaturalEventCategory): string {
|
||||
return CATEGORY_ICONS[category] || '⚠️';
|
||||
}
|
||||
|
||||
// Wildfires older than 48 hours are filtered out (stale data)
|
||||
const WILDFIRE_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
||||
const client = new NaturalServiceClient('', { fetch: (...args) => globalThis.fetch(...args) });
|
||||
const breaker = createCircuitBreaker<ListNaturalEventsResponse>({ name: 'NaturalEvents', cacheTtlMs: 30 * 60 * 1000, persistCache: true });
|
||||
|
||||
const GDACS_TO_CATEGORY: Record<string, NaturalEventCategory> = {
|
||||
EQ: 'earthquakes',
|
||||
FL: 'floods',
|
||||
TC: 'severeStorms',
|
||||
VO: 'volcanoes',
|
||||
WF: 'wildfires',
|
||||
DR: 'drought',
|
||||
};
|
||||
const emptyFallback: ListNaturalEventsResponse = { events: [] };
|
||||
|
||||
function convertGDACSToNaturalEvent(gdacs: GDACSEvent): NaturalEvent {
|
||||
const category = GDACS_TO_CATEGORY[gdacs.eventType] || 'manmade';
|
||||
function toNaturalEvent(e: ListNaturalEventsResponse['events'][number]): NaturalEvent {
|
||||
return {
|
||||
id: gdacs.id,
|
||||
title: `${gdacs.alertLevel === 'Red' ? '🔴 ' : gdacs.alertLevel === 'Orange' ? '🟠 ' : ''}${gdacs.name}`,
|
||||
description: `${gdacs.description}${gdacs.severity ? ` - ${gdacs.severity}` : ''}`,
|
||||
category,
|
||||
categoryTitle: gdacs.description,
|
||||
lat: gdacs.coordinates[1],
|
||||
lon: gdacs.coordinates[0],
|
||||
date: gdacs.fromDate,
|
||||
sourceUrl: gdacs.url,
|
||||
sourceName: 'GDACS',
|
||||
closed: false,
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
description: e.description || undefined,
|
||||
category: (e.category || 'manmade') as NaturalEventCategory,
|
||||
categoryTitle: e.categoryTitle,
|
||||
lat: e.lat,
|
||||
lon: e.lon,
|
||||
date: new Date(e.date),
|
||||
magnitude: e.magnitude ?? undefined,
|
||||
magnitudeUnit: e.magnitudeUnit ?? undefined,
|
||||
sourceUrl: e.sourceUrl || undefined,
|
||||
sourceName: e.sourceName || undefined,
|
||||
closed: e.closed,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchNaturalEvents(days = 30): Promise<NaturalEvent[]> {
|
||||
const [eonetEvents, gdacsEvents] = await Promise.all([
|
||||
fetchEonetEvents(days),
|
||||
fetchGDACSEvents(),
|
||||
]);
|
||||
export async function fetchNaturalEvents(_days = 30): Promise<NaturalEvent[]> {
|
||||
const hydrated = getHydratedData('naturalEvents') as ListNaturalEventsResponse | undefined;
|
||||
const response = hydrated ?? await breaker.execute(async () => {
|
||||
return client.listNaturalEvents({ days: 30 });
|
||||
}, emptyFallback);
|
||||
|
||||
const gdacsConverted = gdacsEvents.map(convertGDACSToNaturalEvent);
|
||||
const seenLocations = new Set<string>();
|
||||
const merged: NaturalEvent[] = [];
|
||||
|
||||
for (const event of gdacsConverted) {
|
||||
const key = `${event.lat.toFixed(1)}-${event.lon.toFixed(1)}-${event.category}`;
|
||||
if (!seenLocations.has(key)) {
|
||||
seenLocations.add(key);
|
||||
merged.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of eonetEvents) {
|
||||
const key = `${event.lat.toFixed(1)}-${event.lon.toFixed(1)}-${event.category}`;
|
||||
if (!seenLocations.has(key)) {
|
||||
seenLocations.add(key);
|
||||
merged.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function fetchEonetEvents(days: number): Promise<NaturalEvent[]> {
|
||||
try {
|
||||
const url = `${EONET_API_URL}?status=open&days=${days}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`EONET API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: EonetResponse = await response.json();
|
||||
const events: NaturalEvent[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const event of data.events) {
|
||||
const category = event.categories[0];
|
||||
if (!category) continue;
|
||||
|
||||
// Skip earthquakes - USGS provides better data for seismic events
|
||||
if (category.id === 'earthquakes') continue;
|
||||
|
||||
// Get most recent geometry point
|
||||
const latestGeo = event.geometry[event.geometry.length - 1];
|
||||
if (!latestGeo || latestGeo.type !== 'Point') continue;
|
||||
|
||||
const eventDate = new Date(latestGeo.date);
|
||||
const [lon, lat] = latestGeo.coordinates;
|
||||
const source = event.sources[0];
|
||||
|
||||
// Filter out wildfires older than 48 hours
|
||||
if (category.id === 'wildfires' && now - eventDate.getTime() > WILDFIRE_MAX_AGE_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
events.push({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
description: event.description || undefined,
|
||||
category: category.id as NaturalEventCategory,
|
||||
categoryTitle: category.title,
|
||||
lat,
|
||||
lon,
|
||||
date: eventDate,
|
||||
magnitude: latestGeo.magnitudeValue,
|
||||
magnitudeUnit: latestGeo.magnitudeUnit,
|
||||
sourceUrl: source?.url,
|
||||
sourceName: source?.id,
|
||||
closed: event.closed !== null,
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
} catch (error) {
|
||||
console.error('[EONET] Failed to fetch natural events:', error);
|
||||
return [];
|
||||
}
|
||||
return (response.events || []).map(toNaturalEvent);
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { createCircuitBreaker } from '@/utils';
|
||||
|
||||
export interface GDACSEvent {
|
||||
id: string;
|
||||
eventType: 'EQ' | 'FL' | 'TC' | 'VO' | 'WF' | 'DR';
|
||||
name: string;
|
||||
description: string;
|
||||
alertLevel: 'Green' | 'Orange' | 'Red';
|
||||
country: string;
|
||||
coordinates: [number, number];
|
||||
fromDate: Date;
|
||||
severity: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface GDACSFeature {
|
||||
geometry: {
|
||||
type: string;
|
||||
coordinates: [number, number];
|
||||
};
|
||||
properties: {
|
||||
eventtype: string;
|
||||
eventid: number;
|
||||
name: string;
|
||||
description: string;
|
||||
alertlevel: string;
|
||||
country: string;
|
||||
fromdate: string;
|
||||
severitydata?: {
|
||||
severity: number;
|
||||
severitytext: string;
|
||||
severityunit: string;
|
||||
};
|
||||
url: {
|
||||
report: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface GDACSResponse {
|
||||
features: GDACSFeature[];
|
||||
}
|
||||
|
||||
const GDACS_API = 'https://www.gdacs.org/gdacsapi/api/events/geteventlist/MAP';
|
||||
const breaker = createCircuitBreaker<GDACSEvent[]>({ name: 'GDACS', cacheTtlMs: 10 * 60 * 1000, persistCache: true });
|
||||
|
||||
const EVENT_TYPE_NAMES: Record<string, string> = {
|
||||
EQ: 'Earthquake',
|
||||
FL: 'Flood',
|
||||
TC: 'Tropical Cyclone',
|
||||
VO: 'Volcano',
|
||||
WF: 'Wildfire',
|
||||
DR: 'Drought',
|
||||
};
|
||||
|
||||
export async function fetchGDACSEvents(): Promise<GDACSEvent[]> {
|
||||
return breaker.execute(async () => {
|
||||
const response = await fetch(GDACS_API, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const data: GDACSResponse = await response.json();
|
||||
|
||||
const seen = new Set<string>();
|
||||
return data.features
|
||||
.filter(f => {
|
||||
if (!f.geometry || f.geometry.type !== 'Point') return false;
|
||||
const key = `${f.properties.eventtype}-${f.properties.eventid}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.filter(f => f.properties.alertlevel !== 'Green')
|
||||
.slice(0, 100)
|
||||
.map(f => ({
|
||||
id: `gdacs-${f.properties.eventtype}-${f.properties.eventid}`,
|
||||
eventType: f.properties.eventtype as GDACSEvent['eventType'],
|
||||
name: f.properties.name,
|
||||
description: f.properties.description || EVENT_TYPE_NAMES[f.properties.eventtype] || f.properties.eventtype,
|
||||
alertLevel: f.properties.alertlevel as GDACSEvent['alertLevel'],
|
||||
country: f.properties.country,
|
||||
coordinates: f.geometry.coordinates,
|
||||
fromDate: new Date(f.properties.fromdate),
|
||||
severity: f.properties.severitydata?.severitytext || '',
|
||||
url: f.properties.url?.report || '',
|
||||
}));
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function getGDACSStatus(): string {
|
||||
return breaker.getStatus();
|
||||
}
|
||||
|
||||
export function getEventTypeIcon(type: GDACSEvent['eventType']): string {
|
||||
switch (type) {
|
||||
case 'EQ': return '🌍';
|
||||
case 'FL': return '🌊';
|
||||
case 'TC': return '🌀';
|
||||
case 'VO': return '🌋';
|
||||
case 'WF': return '🔥';
|
||||
case 'DR': return '☀️';
|
||||
default: return '⚠️';
|
||||
}
|
||||
}
|
||||
|
||||
export function getAlertColor(level: GDACSEvent['alertLevel']): [number, number, number, number] {
|
||||
switch (level) {
|
||||
case 'Red': return [255, 0, 0, 200];
|
||||
case 'Orange': return [255, 140, 0, 180];
|
||||
default: return [255, 200, 0, 160];
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,7 @@ function sebufApiPlugin(): Plugin {
|
||||
givingServerMod, givingHandlerMod,
|
||||
tradeServerMod, tradeHandlerMod,
|
||||
supplyChainServerMod, supplyChainHandlerMod,
|
||||
naturalServerMod, naturalHandlerMod,
|
||||
] = await Promise.all([
|
||||
import('./server/router'),
|
||||
import('./server/cors'),
|
||||
@@ -242,6 +243,8 @@ function sebufApiPlugin(): Plugin {
|
||||
import('./server/worldmonitor/trade/v1/handler'),
|
||||
import('./src/generated/server/worldmonitor/supply_chain/v1/service_server'),
|
||||
import('./server/worldmonitor/supply-chain/v1/handler'),
|
||||
import('./src/generated/server/worldmonitor/natural/v1/service_server'),
|
||||
import('./server/worldmonitor/natural/v1/handler'),
|
||||
]);
|
||||
|
||||
const serverOptions = { onError: errorMod.mapErrorToResponse };
|
||||
@@ -267,6 +270,7 @@ function sebufApiPlugin(): Plugin {
|
||||
...givingServerMod.createGivingServiceRoutes(givingHandlerMod.givingHandler, serverOptions),
|
||||
...tradeServerMod.createTradeServiceRoutes(tradeHandlerMod.tradeHandler, serverOptions),
|
||||
...supplyChainServerMod.createSupplyChainServiceRoutes(supplyChainHandlerMod.supplyChainHandler, serverOptions),
|
||||
...naturalServerMod.createNaturalServiceRoutes(naturalHandlerMod.naturalHandler, serverOptions),
|
||||
];
|
||||
cachedCorsMod = corsMod;
|
||||
return routerMod.createRouter(allRoutes);
|
||||
|
||||
Reference in New Issue
Block a user