| import { getRpcBaseUrl } from '@/services/rpc-client'; |
| import type { UnrestEvent, ListUnrestEventsResponse } from '@/generated/client/worldmonitor/unrest/v1/service_client'; |
| import type { SocialUnrestEvent, ProtestSeverity, ProtestEventType, ProtestSource } from '@/types'; |
| import { createCircuitBreaker } from '@/utils'; |
| import { getHydratedData } from '@/services/bootstrap'; |
| import { UnrestServiceClient } from '@/services/generated-rpc-clients'; |
|
|
| |
|
|
| const client = new UnrestServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); |
| const unrestBreaker = createCircuitBreaker<ListUnrestEventsResponse>({ |
| name: 'Unrest Events', |
| cacheTtlMs: 10 * 60 * 1000, |
| persistCache: true, |
| }); |
|
|
| |
|
|
| function mapSeverity(s: string): ProtestSeverity { |
| switch (s) { |
| case 'SEVERITY_LEVEL_HIGH': return 'high'; |
| case 'SEVERITY_LEVEL_MEDIUM': return 'medium'; |
| default: return 'low'; |
| } |
| } |
|
|
| function mapEventType(t: string): ProtestEventType { |
| switch (t) { |
| case 'UNREST_EVENT_TYPE_PROTEST': return 'protest'; |
| case 'UNREST_EVENT_TYPE_RIOT': return 'riot'; |
| case 'UNREST_EVENT_TYPE_STRIKE': return 'strike'; |
| case 'UNREST_EVENT_TYPE_DEMONSTRATION': return 'demonstration'; |
| default: return 'civil_unrest'; |
| } |
| } |
|
|
| function mapSourceType(s: string): ProtestSource { |
| switch (s) { |
| case 'UNREST_SOURCE_TYPE_ACLED': return 'acled'; |
| case 'UNREST_SOURCE_TYPE_GDELT': return 'gdelt'; |
| default: return 'rss'; |
| } |
| } |
|
|
| function mapConfidence(c: string): 'high' | 'medium' | 'low' { |
| switch (c) { |
| case 'CONFIDENCE_LEVEL_HIGH': return 'high'; |
| case 'CONFIDENCE_LEVEL_MEDIUM': return 'medium'; |
| default: return 'low'; |
| } |
| } |
|
|
| |
|
|
| function toSocialUnrestEvent(e: UnrestEvent): SocialUnrestEvent { |
| return { |
| id: e.id, |
| title: e.title, |
| summary: e.summary || undefined, |
| eventType: mapEventType(e.eventType), |
| city: e.city || undefined, |
| country: e.country, |
| region: e.region || undefined, |
| lat: e.location?.latitude ?? 0, |
| lon: e.location?.longitude ?? 0, |
| time: new Date(e.occurredAt), |
| severity: mapSeverity(e.severity), |
| fatalities: e.fatalities > 0 ? e.fatalities : undefined, |
| sources: e.sources, |
| sourceUrls: e.sourceUrls?.length ? e.sourceUrls : undefined, |
| sourceType: mapSourceType(e.sourceType), |
| tags: e.tags.length > 0 ? e.tags : undefined, |
| actors: e.actors.length > 0 ? e.actors : undefined, |
| confidence: mapConfidence(e.confidence), |
| validated: mapConfidence(e.confidence) === 'high', |
| }; |
| } |
|
|
| |
|
|
| export interface ProtestData { |
| events: SocialUnrestEvent[]; |
| byCountry: Map<string, SocialUnrestEvent[]>; |
| highSeverityCount: number; |
| sources: { acled: number; gdelt: number }; |
| } |
|
|
| |
|
|
| let acledConfigured: boolean | null = null; |
|
|
| |
|
|
| const emptyFallback: ListUnrestEventsResponse = { |
| events: [], |
| clusters: [], |
| pagination: undefined, |
| }; |
|
|
| export async function fetchProtestEvents(): Promise<ProtestData> { |
| const hydrated = getHydratedData('unrestEvents') as ListUnrestEventsResponse | undefined; |
| if (hydrated?.events?.length) { |
| const events = hydrated.events.map(toSocialUnrestEvent); |
| const byCountry = new Map<string, SocialUnrestEvent[]>(); |
| for (const event of events) { |
| const existing = byCountry.get(event.country) || []; |
| existing.push(event); |
| byCountry.set(event.country, existing); |
| } |
| const acledCount = events.filter(e => e.sourceType === 'acled').length; |
| const gdeltCount = events.filter(e => e.sourceType === 'gdelt').length; |
| if (acledCount > 0) acledConfigured = true; |
| else if (gdeltCount > 0) acledConfigured = false; |
| return { events, byCountry, highSeverityCount: events.filter(e => e.severity === 'high').length, sources: { acled: acledCount, gdelt: gdeltCount } }; |
| } |
|
|
| const resp = await unrestBreaker.execute(async () => { |
| return client.listUnrestEvents({ |
| country: '', |
| minSeverity: 'SEVERITY_LEVEL_UNSPECIFIED', |
| start: 0, |
| end: 0, |
| pageSize: 0, |
| cursor: '', |
| neLat: 0, |
| neLon: 0, |
| swLat: 0, |
| swLon: 0, |
| }); |
| }, emptyFallback); |
|
|
| const events = resp.events.map(toSocialUnrestEvent); |
|
|
| |
| const byCountry = new Map<string, SocialUnrestEvent[]>(); |
| for (const event of events) { |
| const existing = byCountry.get(event.country) || []; |
| existing.push(event); |
| byCountry.set(event.country, existing); |
| } |
|
|
| |
| const acledCount = events.filter(e => e.sourceType === 'acled').length; |
| const gdeltCount = events.filter(e => e.sourceType === 'gdelt').length; |
|
|
| |
| if (events.length > 0) { |
| if (acledCount > 0) { |
| acledConfigured = true; |
| } else if (gdeltCount > 0 && acledCount === 0) { |
| acledConfigured = false; |
| } |
| } |
| |
|
|
| return { |
| events, |
| byCountry, |
| highSeverityCount: events.filter(e => e.severity === 'high').length, |
| sources: { |
| acled: acledCount, |
| gdelt: gdeltCount, |
| }, |
| }; |
| } |
|
|
| |
|
|
| export function getProtestStatus(): { acledConfigured: boolean | null; gdeltAvailable: boolean } { |
| return { acledConfigured, gdeltAvailable: true }; |
| } |
|
|