mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(sanctions): add OFAC sanctions pressure intelligence (#1739)
* feat(sanctions): add OFAC sanctions pressure intelligence * fix(sanctions): strip _state from API response, fix code/name alignment, cap seed limit - trimResponse now destructures _state before spreading to prevent seed internals leaking to API clients during the atomicPublish→afterPublish window - buildLocationMap and extractPartyCountries now sort (code, name) as aligned pairs instead of calling uniqueSorted independently on each array; fixes code↔name mispairing for OFAC-specific codes like XC (Crimea) where alphabetic order of codes and names diverges - DEFAULT_RECENT_LIMIT reduced from 120 to 60 to match MAX_ITEMS_LIMIT so seeded entries beyond the handler cap are not written unnecessarily - Add tests/sanctions-pressure.test.mjs covering all three invariants * fix(sanctions): register sanctions:pressure:v1 in health.js BOOTSTRAP_KEYS and SEED_META Adds sanctionsPressure to health.js so the health endpoint monitors the seeded key for emptiness (CRIT) and freshness via seed-meta:sanctions:pressure (maxStaleMin: 720 matches 12h seed TTL). Without this, health was blind to stale or missing sanctions data.
This commit is contained in:
2
api/bootstrap.js
vendored
2
api/bootstrap.js
vendored
@@ -50,6 +50,7 @@ const BOOTSTRAP_CACHE_KEYS = {
|
||||
forecasts: 'forecast:predictions:v2',
|
||||
securityAdvisories: 'intelligence:advisories-bootstrap:v1',
|
||||
customsRevenue: 'trade:customs-revenue:v1',
|
||||
sanctionsPressure: 'sanctions:pressure:v1',
|
||||
};
|
||||
|
||||
const SLOW_KEYS = new Set([
|
||||
@@ -62,6 +63,7 @@ const SLOW_KEYS = new Set([
|
||||
'techEvents',
|
||||
'securityAdvisories',
|
||||
'customsRevenue',
|
||||
'sanctionsPressure',
|
||||
]);
|
||||
const FAST_KEYS = new Set([
|
||||
'earthquakes', 'outages', 'serviceStatuses', 'macroSignals', 'chokepoints', 'chokepointTransits',
|
||||
|
||||
@@ -35,6 +35,7 @@ const BOOTSTRAP_KEYS = {
|
||||
forecasts: 'forecast:predictions:v2',
|
||||
securityAdvisories: 'intelligence:advisories-bootstrap:v1',
|
||||
customsRevenue: 'trade:customs-revenue:v1',
|
||||
sanctionsPressure: 'sanctions:pressure:v1',
|
||||
radiationWatch: 'radiation:observations:v1',
|
||||
};
|
||||
|
||||
@@ -130,6 +131,7 @@ const SEED_META = {
|
||||
usniFleet: { key: 'seed-meta:military:usni-fleet', maxStaleMin: 420 },
|
||||
securityAdvisories: { key: 'seed-meta:intelligence:advisories', maxStaleMin: 90 },
|
||||
customsRevenue: { key: 'seed-meta:trade:customs-revenue', maxStaleMin: 1440 },
|
||||
sanctionsPressure: { key: 'seed-meta:sanctions:pressure', maxStaleMin: 720 },
|
||||
radiationWatch: { key: 'seed-meta:radiation:observations', maxStaleMin: 30 },
|
||||
};
|
||||
|
||||
|
||||
9
api/sanctions/v1/[rpc].ts
Normal file
9
api/sanctions/v1/[rpc].ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const config = { runtime: 'edge' };
|
||||
|
||||
import { createDomainGateway, serverOptions } from '../../../server/gateway';
|
||||
import { createSanctionsServiceRoutes } from '../../../src/generated/server/worldmonitor/sanctions/v1/service_server';
|
||||
import { sanctionsHandler } from '../../../server/worldmonitor/sanctions/v1/handler';
|
||||
|
||||
export default createDomainGateway(
|
||||
createSanctionsServiceRoutes(sanctionsHandler, serverOptions),
|
||||
);
|
||||
1
docs/api/SanctionsService.openapi.json
Normal file
1
docs/api/SanctionsService.openapi.json
Normal file
@@ -0,0 +1 @@
|
||||
{"components":{"schemas":{"CountrySanctionsPressure":{"description":"CountrySanctionsPressure summarizes designation volume and recent additions by country.","properties":{"aircraftCount":{"format":"int32","type":"integer"},"countryCode":{"type":"string"},"countryName":{"type":"string"},"entryCount":{"format":"int32","type":"integer"},"newEntryCount":{"format":"int32","type":"integer"},"vesselCount":{"format":"int32","type":"integer"}},"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"},"ListSanctionsPressureRequest":{"description":"ListSanctionsPressureRequest retrieves recent OFAC sanctions pressure state.","properties":{"maxItems":{"format":"int32","type":"integer"}},"type":"object"},"ListSanctionsPressureResponse":{"description":"ListSanctionsPressureResponse contains normalized OFAC pressure summaries and recent entries.","properties":{"aircraftCount":{"format":"int32","type":"integer"},"consolidatedCount":{"format":"int32","type":"integer"},"countries":{"items":{"$ref":"#/components/schemas/CountrySanctionsPressure"},"type":"array"},"datasetDate":{"format":"int64","type":"string"},"entries":{"items":{"$ref":"#/components/schemas/SanctionsEntry"},"type":"array"},"fetchedAt":{"format":"int64","type":"string"},"newEntryCount":{"format":"int32","type":"integer"},"programs":{"items":{"$ref":"#/components/schemas/ProgramSanctionsPressure"},"type":"array"},"sdnCount":{"format":"int32","type":"integer"},"totalCount":{"format":"int32","type":"integer"},"vesselCount":{"format":"int32","type":"integer"}},"type":"object"},"ProgramSanctionsPressure":{"description":"ProgramSanctionsPressure summarizes designation volume and recent additions by OFAC program.","properties":{"entryCount":{"format":"int32","type":"integer"},"newEntryCount":{"format":"int32","type":"integer"},"program":{"type":"string"}},"type":"object"},"SanctionsEntry":{"description":"SanctionsEntry is a normalized OFAC sanctions designation.","properties":{"countryCodes":{"items":{"type":"string"},"type":"array"},"countryNames":{"items":{"type":"string"},"type":"array"},"effectiveAt":{"format":"int64","type":"string"},"entityType":{"description":"SanctionsEntityType classifies the designated party.","enum":["SANCTIONS_ENTITY_TYPE_UNSPECIFIED","SANCTIONS_ENTITY_TYPE_ENTITY","SANCTIONS_ENTITY_TYPE_INDIVIDUAL","SANCTIONS_ENTITY_TYPE_VESSEL","SANCTIONS_ENTITY_TYPE_AIRCRAFT"],"type":"string"},"id":{"type":"string"},"isNew":{"type":"boolean"},"name":{"type":"string"},"note":{"type":"string"},"programs":{"items":{"type":"string"},"type":"array"},"sourceLists":{"items":{"type":"string"},"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":"SanctionsService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/sanctions/v1/list-sanctions-pressure":{"get":{"description":"ListSanctionsPressure retrieves normalized OFAC designation summaries and recent additions.","operationId":"ListSanctionsPressure","parameters":[{"in":"query","name":"max_items","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListSanctionsPressureResponse"}}},"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":"ListSanctionsPressure","tags":["SanctionsService"]}}}}
|
||||
190
docs/api/SanctionsService.openapi.yaml
Normal file
190
docs/api/SanctionsService.openapi.yaml
Normal file
@@ -0,0 +1,190 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: SanctionsService API
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/sanctions/v1/list-sanctions-pressure:
|
||||
get:
|
||||
tags:
|
||||
- SanctionsService
|
||||
summary: ListSanctionsPressure
|
||||
description: ListSanctionsPressure retrieves normalized OFAC designation summaries and recent additions.
|
||||
operationId: ListSanctionsPressure
|
||||
parameters:
|
||||
- name: max_items
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ListSanctionsPressureResponse'
|
||||
"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.
|
||||
ListSanctionsPressureRequest:
|
||||
type: object
|
||||
properties:
|
||||
maxItems:
|
||||
type: integer
|
||||
format: int32
|
||||
description: ListSanctionsPressureRequest retrieves recent OFAC sanctions pressure state.
|
||||
ListSanctionsPressureResponse:
|
||||
type: object
|
||||
properties:
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SanctionsEntry'
|
||||
countries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CountrySanctionsPressure'
|
||||
programs:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProgramSanctionsPressure'
|
||||
fetchedAt:
|
||||
type: string
|
||||
format: int64
|
||||
datasetDate:
|
||||
type: string
|
||||
format: int64
|
||||
totalCount:
|
||||
type: integer
|
||||
format: int32
|
||||
sdnCount:
|
||||
type: integer
|
||||
format: int32
|
||||
consolidatedCount:
|
||||
type: integer
|
||||
format: int32
|
||||
newEntryCount:
|
||||
type: integer
|
||||
format: int32
|
||||
vesselCount:
|
||||
type: integer
|
||||
format: int32
|
||||
aircraftCount:
|
||||
type: integer
|
||||
format: int32
|
||||
description: ListSanctionsPressureResponse contains normalized OFAC pressure summaries and recent entries.
|
||||
SanctionsEntry:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
entityType:
|
||||
type: string
|
||||
enum:
|
||||
- SANCTIONS_ENTITY_TYPE_UNSPECIFIED
|
||||
- SANCTIONS_ENTITY_TYPE_ENTITY
|
||||
- SANCTIONS_ENTITY_TYPE_INDIVIDUAL
|
||||
- SANCTIONS_ENTITY_TYPE_VESSEL
|
||||
- SANCTIONS_ENTITY_TYPE_AIRCRAFT
|
||||
description: SanctionsEntityType classifies the designated party.
|
||||
countryCodes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
countryNames:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
programs:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
sourceLists:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
effectiveAt:
|
||||
type: string
|
||||
format: int64
|
||||
isNew:
|
||||
type: boolean
|
||||
note:
|
||||
type: string
|
||||
description: SanctionsEntry is a normalized OFAC sanctions designation.
|
||||
CountrySanctionsPressure:
|
||||
type: object
|
||||
properties:
|
||||
countryCode:
|
||||
type: string
|
||||
countryName:
|
||||
type: string
|
||||
entryCount:
|
||||
type: integer
|
||||
format: int32
|
||||
newEntryCount:
|
||||
type: integer
|
||||
format: int32
|
||||
vesselCount:
|
||||
type: integer
|
||||
format: int32
|
||||
aircraftCount:
|
||||
type: integer
|
||||
format: int32
|
||||
description: CountrySanctionsPressure summarizes designation volume and recent additions by country.
|
||||
ProgramSanctionsPressure:
|
||||
type: object
|
||||
properties:
|
||||
program:
|
||||
type: string
|
||||
entryCount:
|
||||
type: integer
|
||||
format: int32
|
||||
newEntryCount:
|
||||
type: integer
|
||||
format: int32
|
||||
description: ProgramSanctionsPressure summarizes designation volume and recent additions by OFAC program.
|
||||
@@ -0,0 +1,13 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.sanctions.v1;
|
||||
|
||||
// CountrySanctionsPressure summarizes designation volume and recent additions by country.
|
||||
message CountrySanctionsPressure {
|
||||
string country_code = 1;
|
||||
string country_name = 2;
|
||||
int32 entry_count = 3;
|
||||
int32 new_entry_count = 4;
|
||||
int32 vessel_count = 5;
|
||||
int32 aircraft_count = 6;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.sanctions.v1;
|
||||
|
||||
import "sebuf/http/annotations.proto";
|
||||
import "worldmonitor/sanctions/v1/country_sanctions_pressure.proto";
|
||||
import "worldmonitor/sanctions/v1/program_sanctions_pressure.proto";
|
||||
import "worldmonitor/sanctions/v1/sanctions_entry.proto";
|
||||
|
||||
// ListSanctionsPressureRequest retrieves recent OFAC sanctions pressure state.
|
||||
message ListSanctionsPressureRequest {
|
||||
int32 max_items = 1 [(sebuf.http.query) = { name: "max_items" }];
|
||||
}
|
||||
|
||||
// ListSanctionsPressureResponse contains normalized OFAC pressure summaries and recent entries.
|
||||
message ListSanctionsPressureResponse {
|
||||
repeated SanctionsEntry entries = 1;
|
||||
repeated CountrySanctionsPressure countries = 2;
|
||||
repeated ProgramSanctionsPressure programs = 3;
|
||||
int64 fetched_at = 4;
|
||||
int64 dataset_date = 5;
|
||||
int32 total_count = 6;
|
||||
int32 sdn_count = 7;
|
||||
int32 consolidated_count = 8;
|
||||
int32 new_entry_count = 9;
|
||||
int32 vessel_count = 10;
|
||||
int32 aircraft_count = 11;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.sanctions.v1;
|
||||
|
||||
// ProgramSanctionsPressure summarizes designation volume and recent additions by OFAC program.
|
||||
message ProgramSanctionsPressure {
|
||||
string program = 1;
|
||||
int32 entry_count = 2;
|
||||
int32 new_entry_count = 3;
|
||||
}
|
||||
26
proto/worldmonitor/sanctions/v1/sanctions_entry.proto
Normal file
26
proto/worldmonitor/sanctions/v1/sanctions_entry.proto
Normal file
@@ -0,0 +1,26 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.sanctions.v1;
|
||||
|
||||
// SanctionsEntityType classifies the designated party.
|
||||
enum SanctionsEntityType {
|
||||
SANCTIONS_ENTITY_TYPE_UNSPECIFIED = 0;
|
||||
SANCTIONS_ENTITY_TYPE_ENTITY = 1;
|
||||
SANCTIONS_ENTITY_TYPE_INDIVIDUAL = 2;
|
||||
SANCTIONS_ENTITY_TYPE_VESSEL = 3;
|
||||
SANCTIONS_ENTITY_TYPE_AIRCRAFT = 4;
|
||||
}
|
||||
|
||||
// SanctionsEntry is a normalized OFAC sanctions designation.
|
||||
message SanctionsEntry {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
SanctionsEntityType entity_type = 3;
|
||||
repeated string country_codes = 4;
|
||||
repeated string country_names = 5;
|
||||
repeated string programs = 6;
|
||||
repeated string source_lists = 7;
|
||||
int64 effective_at = 8;
|
||||
bool is_new = 9;
|
||||
string note = 10;
|
||||
}
|
||||
16
proto/worldmonitor/sanctions/v1/service.proto
Normal file
16
proto/worldmonitor/sanctions/v1/service.proto
Normal file
@@ -0,0 +1,16 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.sanctions.v1;
|
||||
|
||||
import "sebuf/http/annotations.proto";
|
||||
import "worldmonitor/sanctions/v1/list_sanctions_pressure.proto";
|
||||
|
||||
// SanctionsService provides structured OFAC sanctions pressure data.
|
||||
service SanctionsService {
|
||||
option (sebuf.http.service_config) = {base_path: "/api/sanctions/v1"};
|
||||
|
||||
// ListSanctionsPressure retrieves normalized OFAC designation summaries and recent additions.
|
||||
rpc ListSanctionsPressure(ListSanctionsPressureRequest) returns (ListSanctionsPressureResponse) {
|
||||
option (sebuf.http.config) = {path: "/list-sanctions-pressure", method: HTTP_METHOD_GET};
|
||||
}
|
||||
}
|
||||
377
scripts/seed-sanctions-pressure.mjs
Normal file
377
scripts/seed-sanctions-pressure.mjs
Normal file
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
import { CHROME_UA, loadEnvFile, runSeed, verifySeedKey } from './_seed-utils.mjs';
|
||||
|
||||
loadEnvFile(import.meta.url);
|
||||
|
||||
const CANONICAL_KEY = 'sanctions:pressure:v1';
|
||||
const STATE_KEY = 'sanctions:pressure:state:v1';
|
||||
const CACHE_TTL = 12 * 60 * 60;
|
||||
const DEFAULT_RECENT_LIMIT = 60;
|
||||
const OFAC_TIMEOUT_MS = 45_000;
|
||||
const PROGRAM_CODE_RE = /^[A-Z0-9][A-Z0-9-]{1,24}$/;
|
||||
|
||||
const OFAC_SOURCES = [
|
||||
{ label: 'SDN', url: 'https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/sdn_advanced.xml' },
|
||||
{ label: 'CONSOLIDATED', url: 'https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/cons_advanced.xml' },
|
||||
];
|
||||
|
||||
const XML_PARSER = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '',
|
||||
removeNSPrefix: true,
|
||||
parseTagValue: false,
|
||||
trimValues: true,
|
||||
});
|
||||
|
||||
function listify(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
return value == null ? [] : [value];
|
||||
}
|
||||
|
||||
function textValue(value) {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (typeof value === 'object') {
|
||||
if (typeof value['#text'] === 'string') return value['#text'].trim();
|
||||
if (typeof value.NamePartValue === 'string') return value.NamePartValue.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildEpoch(parts) {
|
||||
const year = Number(parts?.Year || 0);
|
||||
if (!year) return 0;
|
||||
const month = Math.max(1, Number(parts?.Month || 1));
|
||||
const day = Math.max(1, Number(parts?.Day || 1));
|
||||
return Date.UTC(year, month - 1, day);
|
||||
}
|
||||
|
||||
function uniqueSorted(values) {
|
||||
return [...new Set(values.filter(Boolean).map((value) => String(value).trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function compactNote(value) {
|
||||
const note = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (!note) return '';
|
||||
return note.length > 240 ? `${note.slice(0, 237)}...` : note;
|
||||
}
|
||||
|
||||
function extractDocumentedName(documentedName) {
|
||||
const parts = listify(documentedName?.DocumentedNamePart)
|
||||
.map((part) => textValue(part?.NamePartValue))
|
||||
.filter(Boolean);
|
||||
if (parts.length > 0) return parts.join(' ');
|
||||
return textValue(documentedName);
|
||||
}
|
||||
|
||||
function normalizeDateOfIssue(value) {
|
||||
const epoch = buildEpoch(value);
|
||||
return Number.isFinite(epoch) ? epoch : 0;
|
||||
}
|
||||
|
||||
function buildReferenceMaps(doc) {
|
||||
const refs = doc?.ReferenceValueSets ?? {};
|
||||
const areaCodes = new Map();
|
||||
for (const area of listify(refs?.AreaCodeValues?.AreaCode)) {
|
||||
areaCodes.set(String(area.ID || ''), {
|
||||
code: textValue(area),
|
||||
name: String(area.Description || '').trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const featureTypes = new Map();
|
||||
for (const feature of listify(refs?.FeatureTypeValues?.FeatureType)) {
|
||||
featureTypes.set(String(feature.ID || ''), textValue(feature));
|
||||
}
|
||||
|
||||
const legalBasis = new Map();
|
||||
for (const basis of listify(refs?.LegalBasisValues?.LegalBasis)) {
|
||||
legalBasis.set(String(basis.ID || ''), String(basis.LegalBasisShortRef || textValue(basis) || '').trim());
|
||||
}
|
||||
|
||||
return { areaCodes, featureTypes, legalBasis };
|
||||
}
|
||||
|
||||
function buildLocationMap(doc, areaCodes) {
|
||||
const locations = new Map();
|
||||
for (const location of listify(doc?.Locations?.Location)) {
|
||||
const ids = listify(location?.LocationAreaCode).map((item) => String(item.AreaCodeID || ''));
|
||||
const mapped = ids.map((id) => areaCodes.get(id)).filter(Boolean);
|
||||
// Sort code/name as pairs so codes[i] always corresponds to names[i]
|
||||
const pairs = [...new Map(mapped.map((item) => [item.code, item.name])).entries()]
|
||||
.filter(([code]) => code.length > 0)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
locations.set(String(location.ID || ''), {
|
||||
codes: pairs.map(([code]) => code),
|
||||
names: pairs.map(([, name]) => name),
|
||||
});
|
||||
}
|
||||
return locations;
|
||||
}
|
||||
|
||||
function extractPartyName(profile) {
|
||||
const identities = listify(profile?.Identity);
|
||||
const aliases = identities.flatMap((identity) => listify(identity?.Alias));
|
||||
const primaryAlias = aliases.find((alias) => alias?.Primary === 'true')
|
||||
|| aliases.find((alias) => alias?.AliasTypeID === '1403')
|
||||
|| aliases[0];
|
||||
return extractDocumentedName(primaryAlias?.DocumentedName);
|
||||
}
|
||||
|
||||
function resolveEntityType(profile, featureTypes) {
|
||||
const subtype = String(profile?.PartySubTypeID || '');
|
||||
if (subtype === '1') return 'SANCTIONS_ENTITY_TYPE_VESSEL';
|
||||
if (subtype === '2') return 'SANCTIONS_ENTITY_TYPE_AIRCRAFT';
|
||||
|
||||
const featureNames = listify(profile?.Feature)
|
||||
.map((feature) => featureTypes.get(String(feature?.FeatureTypeID || '')) || '')
|
||||
.filter(Boolean);
|
||||
|
||||
if (featureNames.some((name) => /birth|citizenship|nationality/i.test(name))) {
|
||||
return 'SANCTIONS_ENTITY_TYPE_INDIVIDUAL';
|
||||
}
|
||||
return 'SANCTIONS_ENTITY_TYPE_ENTITY';
|
||||
}
|
||||
|
||||
function extractPartyCountries(profile, featureTypes, locations) {
|
||||
// Use a Map to deduplicate by code while preserving code→name alignment
|
||||
const seen = new Map();
|
||||
|
||||
for (const feature of listify(profile?.Feature)) {
|
||||
const featureType = featureTypes.get(String(feature?.FeatureTypeID || '')) || '';
|
||||
if (!/location/i.test(featureType)) continue;
|
||||
|
||||
const versions = listify(feature?.FeatureVersion);
|
||||
for (const version of versions) {
|
||||
const locationIds = listify(version?.VersionLocation).map((item) => String(item?.LocationID || ''));
|
||||
for (const locationId of locationIds) {
|
||||
const location = locations.get(locationId);
|
||||
if (!location) continue;
|
||||
location.codes.forEach((code, i) => {
|
||||
if (code && !seen.has(code)) seen.set(code, location.names[i] ?? '');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...seen.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
return {
|
||||
countryCodes: sorted.map(([c]) => c),
|
||||
countryNames: sorted.map(([, n]) => n),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPartyMap(doc, featureTypes, locations) {
|
||||
const parties = new Map();
|
||||
|
||||
for (const distinctParty of listify(doc?.DistinctParties?.DistinctParty)) {
|
||||
const profile = distinctParty?.Profile;
|
||||
const profileId = String(profile?.ID || distinctParty?.FixedRef || '');
|
||||
if (!profileId) continue;
|
||||
|
||||
parties.set(profileId, {
|
||||
name: extractPartyName(profile),
|
||||
entityType: resolveEntityType(profile, featureTypes),
|
||||
...extractPartyCountries(profile, featureTypes, locations),
|
||||
});
|
||||
}
|
||||
|
||||
return parties;
|
||||
}
|
||||
|
||||
function extractPrograms(entry) {
|
||||
const directPrograms = listify(entry?.SanctionsMeasure)
|
||||
.map((measure) => textValue(measure?.Comment))
|
||||
.filter((value) => PROGRAM_CODE_RE.test(value));
|
||||
return uniqueSorted(directPrograms);
|
||||
}
|
||||
|
||||
function extractEffectiveAt(entry) {
|
||||
const dates = [];
|
||||
|
||||
for (const event of listify(entry?.EntryEvent)) {
|
||||
const epoch = buildEpoch(event?.Date);
|
||||
if (epoch > 0) dates.push(epoch);
|
||||
}
|
||||
|
||||
for (const measure of listify(entry?.SanctionsMeasure)) {
|
||||
const epoch = buildEpoch(measure?.DatePeriod?.Start?.From || measure?.DatePeriod?.Start);
|
||||
if (epoch > 0) dates.push(epoch);
|
||||
}
|
||||
|
||||
return dates.length > 0 ? Math.max(...dates) : 0;
|
||||
}
|
||||
|
||||
function extractNote(entry, legalBasis) {
|
||||
const comments = listify(entry?.SanctionsMeasure)
|
||||
.map((measure) => textValue(measure?.Comment))
|
||||
.filter((value) => value && !PROGRAM_CODE_RE.test(value));
|
||||
if (comments.length > 0) return compactNote(comments[0]);
|
||||
|
||||
const legal = listify(entry?.EntryEvent)
|
||||
.map((event) => legalBasis.get(String(event?.LegalBasisID || '')) || '')
|
||||
.filter(Boolean);
|
||||
return compactNote(legal[0] || '');
|
||||
}
|
||||
|
||||
function buildEntriesForDocument(doc, sourceLabel) {
|
||||
const { areaCodes, featureTypes, legalBasis } = buildReferenceMaps(doc);
|
||||
const locations = buildLocationMap(doc, areaCodes);
|
||||
const parties = buildPartyMap(doc, featureTypes, locations);
|
||||
const datasetDate = normalizeDateOfIssue(doc?.DateOfIssue);
|
||||
const entries = [];
|
||||
|
||||
for (const entry of listify(doc?.SanctionsEntries?.SanctionsEntry)) {
|
||||
const profileId = String(entry?.ProfileID || '');
|
||||
const party = parties.get(profileId);
|
||||
const name = party?.name || 'Unnamed designation';
|
||||
const programs = extractPrograms(entry);
|
||||
|
||||
entries.push({
|
||||
id: `${sourceLabel}:${String(entry?.ID || profileId || name)}`,
|
||||
name,
|
||||
entityType: party?.entityType || 'SANCTIONS_ENTITY_TYPE_ENTITY',
|
||||
countryCodes: party?.countryCodes ?? [],
|
||||
countryNames: party?.countryNames ?? [],
|
||||
programs: programs.length > 0 ? programs : [sourceLabel],
|
||||
sourceLists: [sourceLabel],
|
||||
effectiveAt: String(extractEffectiveAt(entry)),
|
||||
isNew: false,
|
||||
note: extractNote(entry, legalBasis),
|
||||
});
|
||||
}
|
||||
|
||||
return { entries, datasetDate };
|
||||
}
|
||||
|
||||
function sortEntries(a, b) {
|
||||
return (Number(b.isNew) - Number(a.isNew))
|
||||
|| (Number(b.effectiveAt) - Number(a.effectiveAt))
|
||||
|| a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
function buildCountryPressure(entries) {
|
||||
const map = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
const codes = entry.countryCodes.length > 0 ? entry.countryCodes : ['XX'];
|
||||
const names = entry.countryNames.length > 0 ? entry.countryNames : ['Unknown'];
|
||||
|
||||
codes.forEach((code, index) => {
|
||||
const key = `${code}:${names[index] || names[0] || 'Unknown'}`;
|
||||
const current = map.get(key) || {
|
||||
countryCode: code,
|
||||
countryName: names[index] || names[0] || 'Unknown',
|
||||
entryCount: 0,
|
||||
newEntryCount: 0,
|
||||
vesselCount: 0,
|
||||
aircraftCount: 0,
|
||||
};
|
||||
current.entryCount += 1;
|
||||
if (entry.isNew) current.newEntryCount += 1;
|
||||
if (entry.entityType === 'SANCTIONS_ENTITY_TYPE_VESSEL') current.vesselCount += 1;
|
||||
if (entry.entityType === 'SANCTIONS_ENTITY_TYPE_AIRCRAFT') current.aircraftCount += 1;
|
||||
map.set(key, current);
|
||||
});
|
||||
}
|
||||
|
||||
return [...map.values()]
|
||||
.sort((a, b) => b.newEntryCount - a.newEntryCount || b.entryCount - a.entryCount || a.countryName.localeCompare(b.countryName))
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function buildProgramPressure(entries) {
|
||||
const map = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
const programs = entry.programs.length > 0 ? entry.programs : ['UNSPECIFIED'];
|
||||
for (const program of programs) {
|
||||
const current = map.get(program) || { program, entryCount: 0, newEntryCount: 0 };
|
||||
current.entryCount += 1;
|
||||
if (entry.isNew) current.newEntryCount += 1;
|
||||
map.set(program, current);
|
||||
}
|
||||
}
|
||||
|
||||
return [...map.values()]
|
||||
.sort((a, b) => b.newEntryCount - a.newEntryCount || b.entryCount - a.entryCount || a.program.localeCompare(b.program))
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
async function fetchSource(source) {
|
||||
const response = await fetch(source.url, {
|
||||
headers: { 'User-Agent': CHROME_UA },
|
||||
signal: AbortSignal.timeout(OFAC_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`OFAC ${source.label} HTTP ${response.status}`);
|
||||
}
|
||||
const xml = await response.text();
|
||||
const parsed = XML_PARSER.parse(xml)?.Sanctions;
|
||||
if (!parsed) throw new Error(`OFAC ${source.label} parse returned no Sanctions root`);
|
||||
return buildEntriesForDocument(parsed, source.label);
|
||||
}
|
||||
|
||||
async function fetchSanctionsPressure() {
|
||||
const previousState = await verifySeedKey(STATE_KEY).catch(() => null);
|
||||
const previousIds = new Set(Array.isArray(previousState?.entryIds) ? previousState.entryIds.map((id) => String(id)) : []);
|
||||
const hasPrevious = previousIds.size > 0;
|
||||
|
||||
const results = await Promise.all(OFAC_SOURCES.map((source) => fetchSource(source)));
|
||||
const entries = results.flatMap((result) => result.entries);
|
||||
const datasetDate = results.reduce((max, result) => Math.max(max, result.datasetDate || 0), 0);
|
||||
|
||||
if (hasPrevious) {
|
||||
for (const entry of entries) {
|
||||
entry.isNew = !previousIds.has(entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedEntries = [...entries].sort(sortEntries);
|
||||
const totalCount = entries.length;
|
||||
const newEntryCount = hasPrevious ? entries.filter((entry) => entry.isNew).length : 0;
|
||||
const vesselCount = entries.filter((entry) => entry.entityType === 'SANCTIONS_ENTITY_TYPE_VESSEL').length;
|
||||
const aircraftCount = entries.filter((entry) => entry.entityType === 'SANCTIONS_ENTITY_TYPE_AIRCRAFT').length;
|
||||
|
||||
return {
|
||||
fetchedAt: String(Date.now()),
|
||||
datasetDate: String(datasetDate),
|
||||
totalCount,
|
||||
sdnCount: results[0]?.entries.length ?? 0,
|
||||
consolidatedCount: results[1]?.entries.length ?? 0,
|
||||
newEntryCount,
|
||||
vesselCount,
|
||||
aircraftCount,
|
||||
countries: buildCountryPressure(entries),
|
||||
programs: buildProgramPressure(entries),
|
||||
entries: sortedEntries.slice(0, DEFAULT_RECENT_LIMIT),
|
||||
_state: {
|
||||
entryIds: entries.map((entry) => entry.id),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validate(data) {
|
||||
return (data?.totalCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
runSeed('sanctions', 'pressure', CANONICAL_KEY, fetchSanctionsPressure, {
|
||||
ttlSeconds: CACHE_TTL,
|
||||
validateFn: validate,
|
||||
sourceVersion: 'ofac-sls-advanced-xml-v1',
|
||||
recordCount: (data) => data.totalCount ?? 0,
|
||||
extraKeys: [
|
||||
{
|
||||
key: STATE_KEY,
|
||||
ttl: CACHE_TTL,
|
||||
transform: (data) => data._state,
|
||||
},
|
||||
],
|
||||
afterPublish: async (data, _ctx) => {
|
||||
delete data._state;
|
||||
},
|
||||
});
|
||||
@@ -48,6 +48,7 @@ export const BOOTSTRAP_CACHE_KEYS: Record<string, string> = {
|
||||
securityAdvisories: 'intelligence:advisories-bootstrap:v1',
|
||||
forecasts: 'forecast:predictions:v2',
|
||||
customsRevenue: 'trade:customs-revenue:v1',
|
||||
sanctionsPressure: 'sanctions:pressure:v1',
|
||||
};
|
||||
|
||||
export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
|
||||
@@ -55,7 +56,7 @@ export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
|
||||
minerals: 'slow', giving: 'slow', sectors: 'slow',
|
||||
progressData: 'slow', renewableEnergy: 'slow',
|
||||
etfFlows: 'slow', shippingRates: 'fast', wildfires: 'slow',
|
||||
climateAnomalies: 'slow', radiationWatch: 'slow', cyberThreats: 'slow', techReadiness: 'slow',
|
||||
climateAnomalies: 'slow', sanctionsPressure: 'slow', radiationWatch: 'slow', cyberThreats: 'slow', techReadiness: 'slow',
|
||||
theaterPosture: 'fast', naturalEvents: 'slow',
|
||||
cryptoQuotes: 'slow', gulfQuotes: 'slow', stablecoinMarkets: 'slow',
|
||||
unrestEvents: 'slow', ucdpEvents: 'slow', techEvents: 'slow',
|
||||
|
||||
@@ -89,6 +89,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||||
'/api/giving/v1/get-giving-summary': 'static',
|
||||
'/api/intelligence/v1/get-country-intel-brief': 'static',
|
||||
'/api/climate/v1/list-climate-anomalies': 'static',
|
||||
'/api/sanctions/v1/list-sanctions-pressure': 'static',
|
||||
'/api/radiation/v1/list-radiation-observations': 'slow',
|
||||
'/api/research/v1/list-tech-events': 'static',
|
||||
'/api/military/v1/get-usni-fleet-report': 'static',
|
||||
|
||||
7
server/worldmonitor/sanctions/v1/handler.ts
Normal file
7
server/worldmonitor/sanctions/v1/handler.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { SanctionsServiceHandler } from '../../../../src/generated/server/worldmonitor/sanctions/v1/service_server';
|
||||
|
||||
import { listSanctionsPressure } from './list-sanctions-pressure';
|
||||
|
||||
export const sanctionsHandler: SanctionsServiceHandler = {
|
||||
listSanctionsPressure,
|
||||
};
|
||||
424
server/worldmonitor/sanctions/v1/list-sanctions-pressure.ts
Normal file
424
server/worldmonitor/sanctions/v1/list-sanctions-pressure.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
import type {
|
||||
ListSanctionsPressureRequest,
|
||||
ListSanctionsPressureResponse,
|
||||
ProgramSanctionsPressure,
|
||||
SanctionsEntityType,
|
||||
SanctionsEntry,
|
||||
SanctionsServiceHandler,
|
||||
ServerContext,
|
||||
CountrySanctionsPressure,
|
||||
} from '../../../../src/generated/server/worldmonitor/sanctions/v1/service_server';
|
||||
import { CHROME_UA } from '../../../_shared/constants';
|
||||
import { cachedFetchJson, getCachedJson } from '../../../_shared/redis';
|
||||
|
||||
const REDIS_CACHE_KEY = 'sanctions:pressure:v1';
|
||||
const REDIS_CACHE_TTL = 30 * 60;
|
||||
const SEED_FRESHNESS_MS = 18 * 60 * 60 * 1000;
|
||||
const DEFAULT_MAX_ITEMS = 25;
|
||||
const MAX_ITEMS_LIMIT = 60;
|
||||
const OFAC_TIMEOUT_MS = 12_000;
|
||||
const PROGRAM_CODE_RE = /^[A-Z0-9][A-Z0-9-]{1,24}$/;
|
||||
|
||||
const OFAC_SOURCES = [
|
||||
{ label: 'SDN', url: 'https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/sdn_advanced.xml' },
|
||||
{ label: 'CONSOLIDATED', url: 'https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/cons_advanced.xml' },
|
||||
] as const;
|
||||
|
||||
const XML_PARSER = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '',
|
||||
removeNSPrefix: true,
|
||||
parseTagValue: false,
|
||||
trimValues: true,
|
||||
});
|
||||
|
||||
type InternalEntry = SanctionsEntry;
|
||||
|
||||
function toInt64String(value: number): string {
|
||||
return String(Math.max(0, Math.trunc(value)));
|
||||
}
|
||||
|
||||
function listify<T>(value: T | T[] | null | undefined): T[] {
|
||||
if (Array.isArray(value)) return value;
|
||||
return value == null ? [] : [value];
|
||||
}
|
||||
|
||||
function textValue(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (typeof obj['#text'] === 'string') return obj['#text'].trim();
|
||||
if (typeof obj.NamePartValue === 'string') return obj.NamePartValue.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildEpoch(parts: Record<string, unknown> | undefined): number {
|
||||
const year = Number(parts?.Year || 0);
|
||||
if (!year) return 0;
|
||||
const month = Math.max(1, Number(parts?.Month || 1));
|
||||
const day = Math.max(1, Number(parts?.Day || 1));
|
||||
return Date.UTC(year, month - 1, day);
|
||||
}
|
||||
|
||||
function uniqueSorted(values: string[]): string[] {
|
||||
return [...new Set(values.filter(Boolean).map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function compactNote(value: string): string {
|
||||
const note = value.replace(/\s+/g, ' ').trim();
|
||||
if (!note) return '';
|
||||
return note.length > 240 ? `${note.slice(0, 237)}...` : note;
|
||||
}
|
||||
|
||||
function extractDocumentedName(documentedName: Record<string, unknown> | undefined): string {
|
||||
const parts = listify(documentedName?.DocumentedNamePart as Record<string, unknown> | Record<string, unknown>[])
|
||||
.map((part) => textValue((part as Record<string, unknown>)?.NamePartValue))
|
||||
.filter(Boolean);
|
||||
if (parts.length > 0) return parts.join(' ');
|
||||
return textValue(documentedName);
|
||||
}
|
||||
|
||||
function buildReferenceMaps(doc: Record<string, unknown>) {
|
||||
const refs = (doc.ReferenceValueSets ?? {}) as Record<string, unknown>;
|
||||
const areaCodes = new Map<string, { code: string; name: string }>();
|
||||
for (const area of listify((refs.AreaCodeValues as Record<string, unknown> | undefined)?.AreaCode as Record<string, unknown> | Record<string, unknown>[])) {
|
||||
areaCodes.set(String(area.ID || ''), {
|
||||
code: textValue(area),
|
||||
name: String(area.Description || '').trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const featureTypes = new Map<string, string>();
|
||||
for (const feature of listify((refs.FeatureTypeValues as Record<string, unknown> | undefined)?.FeatureType as Record<string, unknown> | Record<string, unknown>[])) {
|
||||
featureTypes.set(String(feature.ID || ''), textValue(feature));
|
||||
}
|
||||
|
||||
const legalBasis = new Map<string, string>();
|
||||
for (const basis of listify((refs.LegalBasisValues as Record<string, unknown> | undefined)?.LegalBasis as Record<string, unknown> | Record<string, unknown>[])) {
|
||||
legalBasis.set(String(basis.ID || ''), String(basis.LegalBasisShortRef || textValue(basis) || '').trim());
|
||||
}
|
||||
|
||||
return { areaCodes, featureTypes, legalBasis };
|
||||
}
|
||||
|
||||
function buildLocationMap(doc: Record<string, unknown>, areaCodes: Map<string, { code: string; name: string }>) {
|
||||
const locations = new Map<string, { codes: string[]; names: string[] }>();
|
||||
for (const location of listify(((doc.Locations as Record<string, unknown> | undefined)?.Location) as Record<string, unknown> | Record<string, unknown>[])) {
|
||||
const ids = listify(location.LocationAreaCode as Record<string, unknown> | Record<string, unknown>[]).map((item) => String(item.AreaCodeID || ''));
|
||||
const mapped = ids.map((id) => areaCodes.get(id)).filter((item): item is { code: string; name: string } => Boolean(item));
|
||||
// Sort code/name as pairs so codes[i] always corresponds to names[i]
|
||||
const pairs = [...new Map(mapped.map((item) => [item.code, item.name] as [string, string])).entries()]
|
||||
.filter(([code]) => code.length > 0)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
locations.set(String(location.ID || ''), {
|
||||
codes: pairs.map(([code]) => code),
|
||||
names: pairs.map(([, name]) => name),
|
||||
});
|
||||
}
|
||||
return locations;
|
||||
}
|
||||
|
||||
function extractPartyName(profile: Record<string, unknown>): string {
|
||||
const identities = listify(profile.Identity as Record<string, unknown> | Record<string, unknown>[]);
|
||||
const aliases = identities.flatMap((identity) => listify(identity.Alias as Record<string, unknown> | Record<string, unknown>[]));
|
||||
const primaryAlias = aliases.find((alias) => alias?.Primary === 'true')
|
||||
|| aliases.find((alias) => alias?.AliasTypeID === '1403')
|
||||
|| aliases[0];
|
||||
return extractDocumentedName(primaryAlias?.DocumentedName as Record<string, unknown> | undefined);
|
||||
}
|
||||
|
||||
function resolveEntityType(profile: Record<string, unknown>, featureTypes: Map<string, string>): SanctionsEntityType {
|
||||
const subtype = String(profile.PartySubTypeID || '');
|
||||
if (subtype === '1') return 'SANCTIONS_ENTITY_TYPE_VESSEL';
|
||||
if (subtype === '2') return 'SANCTIONS_ENTITY_TYPE_AIRCRAFT';
|
||||
|
||||
const featureNames = listify(profile.Feature as Record<string, unknown> | Record<string, unknown>[])
|
||||
.map((feature) => featureTypes.get(String(feature?.FeatureTypeID || '')) || '')
|
||||
.filter(Boolean);
|
||||
|
||||
if (featureNames.some((name) => /birth|citizenship|nationality/i.test(name))) {
|
||||
return 'SANCTIONS_ENTITY_TYPE_INDIVIDUAL';
|
||||
}
|
||||
return 'SANCTIONS_ENTITY_TYPE_ENTITY';
|
||||
}
|
||||
|
||||
function extractPartyCountries(
|
||||
profile: Record<string, unknown>,
|
||||
featureTypes: Map<string, string>,
|
||||
locations: Map<string, { codes: string[]; names: string[] }>,
|
||||
): { countryCodes: string[]; countryNames: string[] } {
|
||||
// Use a Map to deduplicate by code while preserving code→name alignment
|
||||
const seen = new Map<string, string>();
|
||||
|
||||
for (const feature of listify(profile.Feature as Record<string, unknown> | Record<string, unknown>[])) {
|
||||
const featureType = featureTypes.get(String(feature?.FeatureTypeID || '')) || '';
|
||||
if (!/location/i.test(featureType)) continue;
|
||||
|
||||
for (const version of listify(feature.FeatureVersion as Record<string, unknown> | Record<string, unknown>[])) {
|
||||
const locationIds = listify(version.VersionLocation as Record<string, unknown> | Record<string, unknown>[]).map((item) => String(item?.LocationID || ''));
|
||||
for (const locationId of locationIds) {
|
||||
const location = locations.get(locationId);
|
||||
if (!location) continue;
|
||||
location.codes.forEach((code, i) => {
|
||||
if (code && !seen.has(code)) seen.set(code, location.names[i] ?? '');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...seen.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
return {
|
||||
countryCodes: sorted.map(([c]) => c),
|
||||
countryNames: sorted.map(([, n]) => n),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPartyMap(
|
||||
doc: Record<string, unknown>,
|
||||
featureTypes: Map<string, string>,
|
||||
locations: Map<string, { codes: string[]; names: string[] }>,
|
||||
) {
|
||||
const parties = new Map<string, { name: string; entityType: SanctionsEntityType; countryCodes: string[]; countryNames: string[] }>();
|
||||
|
||||
for (const distinctParty of listify(((doc.DistinctParties as Record<string, unknown> | undefined)?.DistinctParty) as Record<string, unknown> | Record<string, unknown>[])) {
|
||||
const profile = distinctParty.Profile as Record<string, unknown> | undefined;
|
||||
const profileId = String(profile?.ID || distinctParty.FixedRef || '');
|
||||
if (!profile || !profileId) continue;
|
||||
|
||||
parties.set(profileId, {
|
||||
name: extractPartyName(profile),
|
||||
entityType: resolveEntityType(profile, featureTypes),
|
||||
...extractPartyCountries(profile, featureTypes, locations),
|
||||
});
|
||||
}
|
||||
|
||||
return parties;
|
||||
}
|
||||
|
||||
function extractPrograms(entry: Record<string, unknown>): string[] {
|
||||
const directPrograms = listify(entry.SanctionsMeasure as Record<string, unknown> | Record<string, unknown>[])
|
||||
.map((measure) => textValue(measure?.Comment))
|
||||
.filter((value) => PROGRAM_CODE_RE.test(value));
|
||||
return uniqueSorted(directPrograms);
|
||||
}
|
||||
|
||||
function extractEffectiveAt(entry: Record<string, unknown>): number {
|
||||
const dates: number[] = [];
|
||||
|
||||
for (const event of listify(entry.EntryEvent as Record<string, unknown> | Record<string, unknown>[])) {
|
||||
const epoch = buildEpoch(event.Date as Record<string, unknown> | undefined);
|
||||
if (epoch > 0) dates.push(epoch);
|
||||
}
|
||||
|
||||
for (const measure of listify(entry.SanctionsMeasure as Record<string, unknown> | Record<string, unknown>[])) {
|
||||
const datePeriod = measure.DatePeriod as Record<string, unknown> | undefined;
|
||||
const epoch = buildEpoch((datePeriod?.Start as Record<string, unknown> | undefined)?.From as Record<string, unknown> | undefined || datePeriod?.Start as Record<string, unknown> | undefined);
|
||||
if (epoch > 0) dates.push(epoch);
|
||||
}
|
||||
|
||||
return dates.length > 0 ? Math.max(...dates) : 0;
|
||||
}
|
||||
|
||||
function extractNote(entry: Record<string, unknown>, legalBasis: Map<string, string>): string {
|
||||
const comments = listify(entry.SanctionsMeasure as Record<string, unknown> | Record<string, unknown>[])
|
||||
.map((measure) => textValue(measure?.Comment))
|
||||
.filter((value) => value && !PROGRAM_CODE_RE.test(value));
|
||||
if (comments.length > 0) return compactNote(comments[0]!);
|
||||
|
||||
const legal = listify(entry.EntryEvent as Record<string, unknown> | Record<string, unknown>[])
|
||||
.map((event) => legalBasis.get(String(event?.LegalBasisID || '')) || '')
|
||||
.filter(Boolean);
|
||||
return compactNote(legal[0] || '');
|
||||
}
|
||||
|
||||
function buildEntriesForDocument(doc: Record<string, unknown>, sourceLabel: 'SDN' | 'CONSOLIDATED') {
|
||||
const { areaCodes, featureTypes, legalBasis } = buildReferenceMaps(doc);
|
||||
const locations = buildLocationMap(doc, areaCodes);
|
||||
const parties = buildPartyMap(doc, featureTypes, locations);
|
||||
const datasetDate = buildEpoch(doc.DateOfIssue as Record<string, unknown> | undefined);
|
||||
const entries: InternalEntry[] = [];
|
||||
|
||||
for (const entry of listify(((doc.SanctionsEntries as Record<string, unknown> | undefined)?.SanctionsEntry) as Record<string, unknown> | Record<string, unknown>[])) {
|
||||
const profileId = String(entry.ProfileID || '');
|
||||
const party = parties.get(profileId);
|
||||
const name = party?.name || 'Unnamed designation';
|
||||
const programs = extractPrograms(entry);
|
||||
|
||||
entries.push({
|
||||
id: `${sourceLabel}:${String(entry.ID || profileId || name)}`,
|
||||
name,
|
||||
entityType: party?.entityType || 'SANCTIONS_ENTITY_TYPE_ENTITY',
|
||||
countryCodes: party?.countryCodes ?? [],
|
||||
countryNames: party?.countryNames ?? [],
|
||||
programs: programs.length > 0 ? programs : [sourceLabel],
|
||||
sourceLists: [sourceLabel],
|
||||
effectiveAt: toInt64String(extractEffectiveAt(entry)),
|
||||
isNew: false,
|
||||
note: extractNote(entry, legalBasis),
|
||||
});
|
||||
}
|
||||
|
||||
return { entries, datasetDate };
|
||||
}
|
||||
|
||||
function sortEntries(a: InternalEntry, b: InternalEntry): number {
|
||||
return (Number(b.isNew) - Number(a.isNew))
|
||||
|| (Number(b.effectiveAt) - Number(a.effectiveAt))
|
||||
|| a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
function buildCountryPressure(entries: InternalEntry[]): CountrySanctionsPressure[] {
|
||||
const map = new Map<string, CountrySanctionsPressure>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const codes = entry.countryCodes.length > 0 ? entry.countryCodes : ['XX'];
|
||||
const names = entry.countryNames.length > 0 ? entry.countryNames : ['Unknown'];
|
||||
|
||||
codes.forEach((code, index) => {
|
||||
const key = `${code}:${names[index] || names[0] || 'Unknown'}`;
|
||||
const current = map.get(key) || {
|
||||
countryCode: code,
|
||||
countryName: names[index] || names[0] || 'Unknown',
|
||||
entryCount: 0,
|
||||
newEntryCount: 0,
|
||||
vesselCount: 0,
|
||||
aircraftCount: 0,
|
||||
};
|
||||
current.entryCount += 1;
|
||||
if (entry.isNew) current.newEntryCount += 1;
|
||||
if (entry.entityType === 'SANCTIONS_ENTITY_TYPE_VESSEL') current.vesselCount += 1;
|
||||
if (entry.entityType === 'SANCTIONS_ENTITY_TYPE_AIRCRAFT') current.aircraftCount += 1;
|
||||
map.set(key, current);
|
||||
});
|
||||
}
|
||||
|
||||
return [...map.values()]
|
||||
.sort((a, b) => b.newEntryCount - a.newEntryCount || b.entryCount - a.entryCount || a.countryName.localeCompare(b.countryName))
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function buildProgramPressure(entries: InternalEntry[]): ProgramSanctionsPressure[] {
|
||||
const map = new Map<string, ProgramSanctionsPressure>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const programs = entry.programs.length > 0 ? entry.programs : ['UNSPECIFIED'];
|
||||
for (const program of programs) {
|
||||
const current = map.get(program) || { program, entryCount: 0, newEntryCount: 0 };
|
||||
current.entryCount += 1;
|
||||
if (entry.isNew) current.newEntryCount += 1;
|
||||
map.set(program, current);
|
||||
}
|
||||
}
|
||||
|
||||
return [...map.values()]
|
||||
.sort((a, b) => b.newEntryCount - a.newEntryCount || b.entryCount - a.entryCount || a.program.localeCompare(b.program))
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
async function fetchSource(source: typeof OFAC_SOURCES[number]) {
|
||||
const response = await fetch(source.url, {
|
||||
headers: { 'User-Agent': CHROME_UA },
|
||||
signal: AbortSignal.timeout(OFAC_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`OFAC ${source.label} HTTP ${response.status}`);
|
||||
}
|
||||
const xml = await response.text();
|
||||
const parsed = XML_PARSER.parse(xml)?.Sanctions as Record<string, unknown> | undefined;
|
||||
if (!parsed) throw new Error(`OFAC ${source.label} parse returned no Sanctions root`);
|
||||
return buildEntriesForDocument(parsed, source.label);
|
||||
}
|
||||
|
||||
function trimResponse(data: ListSanctionsPressureResponse, maxItems: number): ListSanctionsPressureResponse {
|
||||
// Destructure out _state which may be present in seeded Redis payloads during the window
|
||||
// between atomicPublish and afterPublish deletion
|
||||
const { _state: _discarded, ...rest } = data as ListSanctionsPressureResponse & { _state?: unknown };
|
||||
return {
|
||||
...rest,
|
||||
fetchedAt: String(data.fetchedAt ?? '0'),
|
||||
datasetDate: String(data.datasetDate ?? '0'),
|
||||
entries: (data.entries ?? []).map((entry) => ({
|
||||
...entry,
|
||||
effectiveAt: String(entry.effectiveAt ?? '0'),
|
||||
})).slice(0, maxItems),
|
||||
};
|
||||
}
|
||||
|
||||
async function trySeededData(maxItems: number): Promise<ListSanctionsPressureResponse | null> {
|
||||
try {
|
||||
const [data, meta] = await Promise.all([
|
||||
getCachedJson(REDIS_CACHE_KEY, true) as Promise<ListSanctionsPressureResponse | null>,
|
||||
getCachedJson('seed-meta:sanctions:pressure', true) as Promise<{ fetchedAt?: number } | null>,
|
||||
]);
|
||||
if (!data || !meta?.fetchedAt) return null;
|
||||
if (Date.now() - meta.fetchedAt > SEED_FRESHNESS_MS) return null;
|
||||
return trimResponse(data, maxItems);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectPressure(maxItems: number): Promise<ListSanctionsPressureResponse> {
|
||||
const results = await Promise.all(OFAC_SOURCES.map((source) => fetchSource(source)));
|
||||
const entries = results.flatMap((result) => result.entries).sort(sortEntries);
|
||||
const totalCount = entries.length;
|
||||
|
||||
return {
|
||||
fetchedAt: toInt64String(Date.now()),
|
||||
datasetDate: toInt64String(results.reduce((max, result) => Math.max(max, result.datasetDate || 0), 0)),
|
||||
totalCount,
|
||||
sdnCount: results[0]?.entries.length ?? 0,
|
||||
consolidatedCount: results[1]?.entries.length ?? 0,
|
||||
newEntryCount: 0,
|
||||
vesselCount: entries.filter((entry) => entry.entityType === 'SANCTIONS_ENTITY_TYPE_VESSEL').length,
|
||||
aircraftCount: entries.filter((entry) => entry.entityType === 'SANCTIONS_ENTITY_TYPE_AIRCRAFT').length,
|
||||
countries: buildCountryPressure(entries),
|
||||
programs: buildProgramPressure(entries),
|
||||
entries: entries.slice(0, maxItems),
|
||||
};
|
||||
}
|
||||
|
||||
function clampMaxItems(value: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0) return DEFAULT_MAX_ITEMS;
|
||||
return Math.min(Math.max(Math.trunc(value), 1), MAX_ITEMS_LIMIT);
|
||||
}
|
||||
|
||||
function emptyResponse(): ListSanctionsPressureResponse {
|
||||
return {
|
||||
entries: [],
|
||||
countries: [],
|
||||
programs: [],
|
||||
fetchedAt: '0',
|
||||
datasetDate: '0',
|
||||
totalCount: 0,
|
||||
sdnCount: 0,
|
||||
consolidatedCount: 0,
|
||||
newEntryCount: 0,
|
||||
vesselCount: 0,
|
||||
aircraftCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const listSanctionsPressure: SanctionsServiceHandler['listSanctionsPressure'] = async (
|
||||
_ctx: ServerContext,
|
||||
req: ListSanctionsPressureRequest,
|
||||
): Promise<ListSanctionsPressureResponse> => {
|
||||
const maxItems = clampMaxItems(req.maxItems);
|
||||
try {
|
||||
const seeded = await trySeededData(maxItems);
|
||||
if (seeded) return seeded;
|
||||
|
||||
return await cachedFetchJson<ListSanctionsPressureResponse>(
|
||||
`${REDIS_CACHE_KEY}:live:${maxItems}`,
|
||||
REDIS_CACHE_TTL,
|
||||
async () => collectPressure(maxItems),
|
||||
) ?? emptyResponse();
|
||||
} catch {
|
||||
return emptyResponse();
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { InternetOutage, SocialUnrestEvent, MilitaryFlight, MilitaryFlightCluster, MilitaryVessel, MilitaryVesselCluster, USNIFleetReport, PanelConfig, MapLayers, NewsItem, MarketData, ClusteredEvent, CyberThreat, Monitor } from '@/types';
|
||||
import type { AirportDelayAlert, PositionSample } from '@/services/aviation';
|
||||
import type { IranEvent } from '@/generated/client/worldmonitor/conflict/v1/service_client';
|
||||
import type { SanctionsPressureResult } from '@/services/sanctions-pressure';
|
||||
import type { RadiationWatchResult } from '@/services/radiation';
|
||||
import type { SecurityAdvisory } from '@/services/security-advisories';
|
||||
import type { Earthquake } from '@/services/earthquakes';
|
||||
@@ -18,6 +19,7 @@ export interface IntelligenceCache {
|
||||
iranEvents?: IranEvent[];
|
||||
orefAlerts?: { alertCount: number; historyCount24h: number };
|
||||
advisories?: SecurityAdvisory[];
|
||||
sanctions?: SanctionsPressureResult;
|
||||
radiation?: RadiationWatchResult;
|
||||
imageryScenes?: Array<{ id: string; satellite: string; datetime: string; resolutionM: number; mode: string; geometryGeojson: string; previewUrl: string; assetUrl: string }>;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
fetchShippingRates,
|
||||
fetchChokepointStatus,
|
||||
fetchCriticalMinerals,
|
||||
fetchSanctionsPressure,
|
||||
fetchRadiationWatch,
|
||||
} from '@/services';
|
||||
import { getMarketWatchlistEntries } from '@/services/market-watchlist';
|
||||
@@ -474,6 +475,9 @@ export class DataLoaderManager implements AppModule {
|
||||
if (SITE_VARIANT !== 'happy' && (this.ctx.mapLayers.techEvents || SITE_VARIANT === 'tech')) tasks.push({ name: 'techEvents', task: runGuarded('techEvents', () => this.loadTechEvents()) });
|
||||
if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.satellites && this.ctx.map?.isGlobeMode?.()) tasks.push({ name: 'satellites', task: runGuarded('satellites', () => this.loadSatellites()) });
|
||||
if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.webcams) tasks.push({ name: 'webcams', task: runGuarded('webcams', () => this.loadWebcams()) });
|
||||
if (SITE_VARIANT !== 'happy' && (this.ctx.panels['sanctions-pressure'] || this.ctx.mapLayers.sanctions)) {
|
||||
tasks.push({ name: 'sanctions', task: runGuarded('sanctions', () => this.loadSanctionsPressure()) });
|
||||
}
|
||||
if (SITE_VARIANT !== 'happy' && (this.ctx.panels['radiation-watch'] || this.ctx.mapLayers.radiationWatch)) {
|
||||
tasks.push({ name: 'radiation', task: runGuarded('radiation', () => this.loadRadiationWatch()) });
|
||||
}
|
||||
@@ -579,6 +583,9 @@ export class DataLoaderManager implements AppModule {
|
||||
case 'webcams':
|
||||
await this.loadWebcams();
|
||||
break;
|
||||
case 'sanctions':
|
||||
await this.loadSanctionsPressure();
|
||||
break;
|
||||
case 'radiationWatch':
|
||||
await this.loadRadiationWatch();
|
||||
break;
|
||||
@@ -2673,6 +2680,25 @@ export class DataLoaderManager implements AppModule {
|
||||
}
|
||||
}
|
||||
|
||||
async loadSanctionsPressure(): Promise<void> {
|
||||
try {
|
||||
const result = await fetchSanctionsPressure();
|
||||
this.callPanel('sanctions-pressure', 'setData', result);
|
||||
this.ctx.intelligenceCache.sanctions = result;
|
||||
signalAggregator.ingestSanctionsPressure(result.countries);
|
||||
if (result.totalCount > 0) {
|
||||
dataFreshness.recordUpdate('sanctions_pressure', result.totalCount);
|
||||
this.ctx.statusPanel?.updateApi('OFAC', { status: result.newEntryCount > 0 ? 'warning' : 'ok' });
|
||||
} else {
|
||||
this.ctx.statusPanel?.updateApi('OFAC', { status: 'error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[App] Sanctions pressure fetch failed:', error);
|
||||
dataFreshness.recordError('sanctions_pressure', String(error));
|
||||
this.ctx.statusPanel?.updateApi('OFAC', { status: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async loadRadiationWatch(): Promise<void> {
|
||||
try {
|
||||
const result = await fetchRadiationWatch();
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
InvestmentsPanel,
|
||||
TradePolicyPanel,
|
||||
SupplyChainPanel,
|
||||
SanctionsPressurePanel,
|
||||
GulfEconomiesPanel,
|
||||
WorldClockPanel,
|
||||
AirlineIntelPanel,
|
||||
@@ -565,6 +566,7 @@ export class PanelLayoutManager implements AppModule {
|
||||
this.createPanel('economic', () => new EconomicPanel());
|
||||
|
||||
this.createPanel('trade-policy', () => new TradePolicyPanel());
|
||||
this.createPanel('sanctions-pressure', () => new SanctionsPressurePanel());
|
||||
this.createPanel('supply-chain', () => new SupplyChainPanel());
|
||||
|
||||
this.createNewsPanel('africa', 'panels.africa');
|
||||
|
||||
152
src/components/SanctionsPressurePanel.ts
Normal file
152
src/components/SanctionsPressurePanel.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Panel } from './Panel';
|
||||
import type { CountrySanctionsPressure, ProgramSanctionsPressure, SanctionsEntry, SanctionsPressureResult } from '@/services/sanctions-pressure';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
|
||||
export class SanctionsPressurePanel extends Panel {
|
||||
private data: SanctionsPressureResult | null = null;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'sanctions-pressure',
|
||||
title: 'Sanctions Pressure',
|
||||
showCount: true,
|
||||
trackActivity: true,
|
||||
defaultRowSpan: 2,
|
||||
infoTooltip: 'Structured OFAC sanctions pressure built from seeded SDN and Consolidated List exports. This panel answers where sanctions pressure is concentrated, what is newly designated, and which programs are driving the latest pressure.',
|
||||
});
|
||||
this.showLoading('Loading sanctions pressure...');
|
||||
}
|
||||
|
||||
public setData(data: SanctionsPressureResult): void {
|
||||
this.data = data;
|
||||
this.setCount(data.totalCount);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (!this.data) {
|
||||
this.setContent('<div class="economic-empty">No sanctions pressure available.</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = this.data;
|
||||
const topCountry = data.countries[0];
|
||||
const topProgram = data.programs[0];
|
||||
const summaryHtml = `
|
||||
<div class="sanctions-summary">
|
||||
${this.renderSummaryCard('New', data.newEntryCount, data.newEntryCount > 0 ? 'highlight' : '')}
|
||||
${this.renderSummaryCard('Countries', data.countries.length)}
|
||||
${this.renderSummaryCard('Programs', data.programs.length)}
|
||||
${this.renderSummaryCard('Vessels', data.vesselCount)}
|
||||
${this.renderSummaryCard('Aircraft', data.aircraftCount)}
|
||||
${this.renderSummaryCard('Source Mix', `${data.sdnCount}/${data.consolidatedCount}`, 'muted')}
|
||||
</div>
|
||||
<div class="sanctions-headlines">
|
||||
<div class="sanctions-headline">
|
||||
<span class="sanctions-headline-label">Top country</span>
|
||||
<span class="sanctions-headline-value">${topCountry ? `${escapeHtml(topCountry.countryName)} (${topCountry.entryCount})` : 'No country attribution'}</span>
|
||||
</div>
|
||||
<div class="sanctions-headline">
|
||||
<span class="sanctions-headline-label">Top program</span>
|
||||
<span class="sanctions-headline-value">${topProgram ? `${escapeHtml(topProgram.program)} (${topProgram.entryCount})` : 'No program breakdown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const countriesHtml = data.countries.length > 0
|
||||
? data.countries.slice(0, 8).map((country) => this.renderCountryRow(country)).join('')
|
||||
: '<div class="economic-empty">No country pressure breakdown available.</div>';
|
||||
const programsHtml = data.programs.length > 0
|
||||
? data.programs.slice(0, 8).map((program) => this.renderProgramRow(program)).join('')
|
||||
: '<div class="economic-empty">No program pressure breakdown available.</div>';
|
||||
const entriesHtml = data.entries.length > 0
|
||||
? data.entries.slice(0, 10).map((entry) => this.renderEntryRow(entry)).join('')
|
||||
: '<div class="economic-empty">No recent designations available.</div>';
|
||||
|
||||
const footer = [
|
||||
`Updated ${data.fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`,
|
||||
data.datasetDate ? `dataset ${data.datasetDate.toISOString().slice(0, 10)}` : '',
|
||||
'Source: OFAC',
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
this.setContent(`
|
||||
<div class="sanctions-panel-content">
|
||||
${summaryHtml}
|
||||
<div class="sanctions-sections">
|
||||
<div class="sanctions-section">
|
||||
<div class="sanctions-section-title">Top countries</div>
|
||||
<div class="sanctions-list">${countriesHtml}</div>
|
||||
</div>
|
||||
<div class="sanctions-section">
|
||||
<div class="sanctions-section-title">Top programs</div>
|
||||
<div class="sanctions-list">${programsHtml}</div>
|
||||
</div>
|
||||
<div class="sanctions-section">
|
||||
<div class="sanctions-section-title">Recent designations</div>
|
||||
<div class="sanctions-list">${entriesHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="economic-footer">${escapeHtml(footer)}</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
private renderSummaryCard(label: string, value: string | number, tone = ''): string {
|
||||
return `
|
||||
<div class="sanctions-summary-card ${tone ? `sanctions-summary-card-${tone}` : ''}">
|
||||
<span class="sanctions-summary-label">${escapeHtml(label)}</span>
|
||||
<span class="sanctions-summary-value">${escapeHtml(String(value))}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCountryRow(country: CountrySanctionsPressure): string {
|
||||
const flags: string[] = [];
|
||||
if (country.newEntryCount > 0) flags.push(`<span class="sanctions-pill sanctions-pill-new">+${country.newEntryCount} new</span>`);
|
||||
if (country.vesselCount > 0) flags.push(`<span class="sanctions-pill">🚢 ${country.vesselCount}</span>`);
|
||||
if (country.aircraftCount > 0) flags.push(`<span class="sanctions-pill">✈ ${country.aircraftCount}</span>`);
|
||||
|
||||
return `
|
||||
<div class="sanctions-row">
|
||||
<div class="sanctions-row-main">
|
||||
<div class="sanctions-row-title">${escapeHtml(country.countryName)}</div>
|
||||
<div class="sanctions-row-meta">${escapeHtml(country.countryCode)} · ${country.entryCount} designations</div>
|
||||
</div>
|
||||
<div class="sanctions-row-flags">${flags.join('')}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderProgramRow(program: ProgramSanctionsPressure): string {
|
||||
return `
|
||||
<div class="sanctions-row">
|
||||
<div class="sanctions-row-main">
|
||||
<div class="sanctions-row-title">${escapeHtml(program.program)}</div>
|
||||
<div class="sanctions-row-meta">${program.entryCount} designations</div>
|
||||
</div>
|
||||
<div class="sanctions-row-flags">
|
||||
${program.newEntryCount > 0 ? `<span class="sanctions-pill sanctions-pill-new">+${program.newEntryCount} new</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEntryRow(entry: SanctionsEntry): string {
|
||||
const location = entry.countryNames[0] || entry.countryCodes[0] || 'Unattributed';
|
||||
const program = entry.programs[0] || 'Program';
|
||||
const note = entry.note ? `<div class="sanctions-entry-note">${escapeHtml(entry.note)}</div>` : '';
|
||||
const effective = entry.effectiveAt ? entry.effectiveAt.toISOString().slice(0, 10) : 'undated';
|
||||
|
||||
return `
|
||||
<div class="sanctions-entry">
|
||||
<div class="sanctions-entry-top">
|
||||
<span class="sanctions-entry-name">${escapeHtml(entry.name)}</span>
|
||||
<span class="sanctions-pill sanctions-pill-type">${escapeHtml(entry.entityType)}</span>
|
||||
${entry.isNew ? '<span class="sanctions-pill sanctions-pill-new">new</span>' : ''}
|
||||
</div>
|
||||
<div class="sanctions-entry-meta">${escapeHtml(location)} · ${escapeHtml(program)} · ${escapeHtml(effective)}</div>
|
||||
${note}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,7 @@ export class SignalModal {
|
||||
cii_spike: '📊',
|
||||
convergence: '🌍',
|
||||
cascade: '⚡',
|
||||
sanctions: '🚫',
|
||||
radiation: '☢️',
|
||||
composite: '🔗',
|
||||
};
|
||||
@@ -206,6 +207,33 @@ export class SignalModal {
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
if (alert.components.sanctions) {
|
||||
const sanctions = alert.components.sanctions;
|
||||
detailsHtml += `
|
||||
<div class="signal-context-item">
|
||||
<span class="context-label">Country</span>
|
||||
<span class="context-value">${escapeHtml(sanctions.countryName)} (${escapeHtml(sanctions.countryCode)})</span>
|
||||
</div>
|
||||
<div class="signal-context-item">
|
||||
<span class="context-label">Pressure</span>
|
||||
<span class="context-value">${sanctions.entryCount} designations${sanctions.newEntryCount > 0 ? ` · +${sanctions.newEntryCount} new` : ''}</span>
|
||||
</div>
|
||||
<div class="signal-context-item">
|
||||
<span class="context-label">Top program</span>
|
||||
<span class="context-value">${escapeHtml(sanctions.topProgram)} (${sanctions.topProgramCount})</span>
|
||||
</div>
|
||||
<div class="signal-context-item">
|
||||
<span class="context-label">Vessels / aircraft</span>
|
||||
<span class="context-value">${sanctions.vesselCount} / ${sanctions.aircraftCount}</span>
|
||||
</div>
|
||||
<div class="signal-context-item">
|
||||
<span class="context-label">Dataset size</span>
|
||||
<span class="context-value">${sanctions.totalCount}${sanctions.datasetDate ? ` · ${new Date(sanctions.datasetDate).toISOString().slice(0, 10)}` : ''}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (alert.components.radiation) {
|
||||
const radiation = alert.components.radiation;
|
||||
detailsHtml += `
|
||||
|
||||
@@ -39,7 +39,7 @@ const WORLD_FEEDS = new Set([
|
||||
const WORLD_APIS = new Set([
|
||||
'RSS2JSON', 'Finnhub', 'CoinGecko', 'Polymarket', 'USGS', 'FRED',
|
||||
'AISStream', 'GDELT Doc', 'EIA', 'USASpending', 'PizzINT', 'FIRMS',
|
||||
'Cyber Threats API', 'BIS', 'WTO', 'SupplyChain'
|
||||
'Cyber Threats API', 'BIS', 'WTO', 'SupplyChain', 'OFAC'
|
||||
]);
|
||||
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
@@ -209,6 +209,7 @@ export class StrategicRiskPanel extends Panel {
|
||||
case 'convergence': return '🎯';
|
||||
case 'cii_spike': return '📊';
|
||||
case 'cascade': return '🔗';
|
||||
case 'sanctions': return '🚫';
|
||||
case 'radiation': return '☢️';
|
||||
case 'composite': return '⚠️';
|
||||
default: return '📍';
|
||||
|
||||
@@ -47,6 +47,7 @@ export * from './UnifiedSettings';
|
||||
export * from './TradePolicyPanel';
|
||||
export * from './SupplyChainPanel';
|
||||
export * from './SecurityAdvisoriesPanel';
|
||||
export * from './SanctionsPressurePanel';
|
||||
export * from './RadiationWatchPanel';
|
||||
export * from './OrefSirensPanel';
|
||||
export * from './TelegramIntelPanel';
|
||||
|
||||
@@ -108,6 +108,7 @@ export const COMMANDS: Command[] = [
|
||||
{ id: 'panel:markets', keywords: ['markets', 'stocks', 'indices'], label: 'Panel: Markets', icon: '\u{1F4C8}', category: 'panels' },
|
||||
{ id: 'panel:economic', keywords: ['economic', 'economy', 'fred'], label: 'Panel: Economic Indicators', icon: '\u{1F4CA}', category: 'panels' },
|
||||
{ id: 'panel:trade-policy', keywords: ['trade', 'tariffs', 'wto', 'trade policy', 'sanctions', 'restrictions'], label: 'Panel: Trade Policy', icon: '\u{1F4CA}', category: 'panels' },
|
||||
{ id: 'panel:sanctions-pressure', keywords: ['sanctions pressure', 'ofac', 'designation', 'sanctions'], label: 'Panel: Sanctions Pressure', icon: '\u{1F6AB}', category: 'panels' },
|
||||
{ id: 'panel:supply-chain', keywords: ['supply chain', 'shipping', 'chokepoint', 'minerals', 'freight', 'logistics'], label: 'Panel: Supply Chain', icon: '\u{1F6A2}', category: 'panels' },
|
||||
{ id: 'panel:finance', keywords: ['financial', 'finance news'], label: 'Panel: Financial', icon: '\u{1F4B5}', category: 'panels' },
|
||||
{ id: 'panel:tech', keywords: ['technology', 'tech news'], label: 'Panel: Technology', icon: '\u{1F4BB}', category: 'panels' },
|
||||
|
||||
@@ -61,6 +61,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
|
||||
climate: { name: 'Climate Anomalies', enabled: true, priority: 2 },
|
||||
'population-exposure': { name: 'Population Exposure', enabled: true, priority: 2 },
|
||||
'security-advisories': { name: 'Security Advisories', enabled: true, priority: 2 },
|
||||
'sanctions-pressure': { name: 'Sanctions Pressure', enabled: true, priority: 2 },
|
||||
'radiation-watch': { name: 'Radiation Watch', enabled: true, priority: 2 },
|
||||
'oref-sirens': { name: 'Israel Sirens', enabled: true, priority: 2, ...(_desktop && { premium: 'locked' as const }) },
|
||||
'telegram-intel': { name: 'Telegram Intel', enabled: true, priority: 2, ...(_desktop && { premium: 'locked' as const }) },
|
||||
@@ -377,6 +378,7 @@ const FINANCE_PANELS: Record<string, PanelConfig> = {
|
||||
centralbanks: { name: 'Central Bank Watch', enabled: true, priority: 1 },
|
||||
economic: { name: 'Economic Data', enabled: true, priority: 1 },
|
||||
'trade-policy': { name: 'Trade Policy', enabled: true, priority: 1 },
|
||||
'sanctions-pressure': { name: 'Sanctions Pressure', enabled: true, priority: 1 },
|
||||
'supply-chain': { name: 'Supply Chain', enabled: true, priority: 1 },
|
||||
'economic-news': { name: 'Economic News', enabled: true, priority: 2 },
|
||||
ipo: { name: 'IPOs, Earnings & M&A', enabled: true, priority: 1 },
|
||||
@@ -675,6 +677,7 @@ const COMMODITY_PANELS: Record<string, PanelConfig> = {
|
||||
heatmap: { name: 'Sector Heatmap', enabled: true, priority: 1 },
|
||||
'macro-signals': { name: 'Market Radar', enabled: true, priority: 1 },
|
||||
'trade-policy': { name: 'Trade Policy', enabled: true, priority: 1 },
|
||||
'sanctions-pressure': { name: 'Sanctions Pressure', enabled: true, priority: 1 },
|
||||
economic: { name: 'Economic Indicators', enabled: true, priority: 1 },
|
||||
'gulf-economies': { name: 'Gulf & OPEC Economies', enabled: true, priority: 1 },
|
||||
'gcc-investments': { name: 'GCC Resource Investments', enabled: true, priority: 2 },
|
||||
@@ -849,6 +852,7 @@ export const LAYER_TO_SOURCE: Partial<Record<keyof MapLayers, DataSourceId[]>> =
|
||||
ucdpEvents: ['ucdp_events'],
|
||||
displacement: ['unhcr'],
|
||||
climate: ['climate'],
|
||||
sanctions: ['sanctions_pressure'],
|
||||
radiationWatch: ['radiation'],
|
||||
};
|
||||
|
||||
@@ -884,7 +888,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
|
||||
},
|
||||
marketsFinance: {
|
||||
labelKey: 'header.panelCatMarketsFinance',
|
||||
panelKeys: ['commodities', 'markets', 'economic', 'trade-policy', 'supply-chain', 'finance', 'polymarket', 'macro-signals', 'gulf-economies', 'etf-flows', 'stablecoins', 'crypto', 'heatmap'],
|
||||
panelKeys: ['commodities', 'markets', 'economic', 'trade-policy', 'sanctions-pressure', 'supply-chain', 'finance', 'polymarket', 'macro-signals', 'gulf-economies', 'etf-flows', 'stablecoins', 'crypto', 'heatmap'],
|
||||
variants: ['full'],
|
||||
},
|
||||
topical: {
|
||||
@@ -916,7 +920,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
|
||||
},
|
||||
techMarkets: {
|
||||
labelKey: 'header.panelCatMarkets',
|
||||
panelKeys: ['markets', 'finance', 'crypto', 'economic', 'polymarket', 'macro-signals', 'etf-flows', 'stablecoins', 'layoffs', 'monitors', 'world-clock'],
|
||||
panelKeys: ['markets', 'finance', 'crypto', 'economic', 'sanctions-pressure', 'polymarket', 'macro-signals', 'etf-flows', 'stablecoins', 'layoffs', 'monitors', 'world-clock'],
|
||||
variants: ['tech'],
|
||||
},
|
||||
|
||||
@@ -943,7 +947,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
|
||||
},
|
||||
centralBanksEcon: {
|
||||
labelKey: 'header.panelCatCentralBanks',
|
||||
panelKeys: ['centralbanks', 'economic', 'trade-policy', 'supply-chain', 'economic-news'],
|
||||
panelKeys: ['centralbanks', 'economic', 'trade-policy', 'sanctions-pressure', 'supply-chain', 'economic-news'],
|
||||
variants: ['finance'],
|
||||
},
|
||||
dealsInstitutional: {
|
||||
@@ -970,7 +974,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
|
||||
},
|
||||
commodityEcon: {
|
||||
labelKey: 'header.panelCatCommodityEcon',
|
||||
panelKeys: ['trade-policy', 'economic', 'gulf-economies', 'gcc-investments', 'finance', 'polymarket', 'airline-intel', 'world-clock', 'monitors'],
|
||||
panelKeys: ['trade-policy', 'sanctions-pressure', 'economic', 'gulf-economies', 'gcc-investments', 'finance', 'polymarket', 'airline-intel', 'world-clock', 'monitors'],
|
||||
variants: ['commodity'],
|
||||
},
|
||||
|
||||
|
||||
141
src/generated/client/worldmonitor/sanctions/v1/service_client.ts
Normal file
141
src/generated/client/worldmonitor/sanctions/v1/service_client.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
|
||||
// source: worldmonitor/sanctions/v1/service.proto
|
||||
|
||||
export interface ListSanctionsPressureRequest {
|
||||
maxItems: number;
|
||||
}
|
||||
|
||||
export interface ListSanctionsPressureResponse {
|
||||
entries: SanctionsEntry[];
|
||||
countries: CountrySanctionsPressure[];
|
||||
programs: ProgramSanctionsPressure[];
|
||||
fetchedAt: string;
|
||||
datasetDate: string;
|
||||
totalCount: number;
|
||||
sdnCount: number;
|
||||
consolidatedCount: number;
|
||||
newEntryCount: number;
|
||||
vesselCount: number;
|
||||
aircraftCount: number;
|
||||
}
|
||||
|
||||
export interface SanctionsEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
entityType: SanctionsEntityType;
|
||||
countryCodes: string[];
|
||||
countryNames: string[];
|
||||
programs: string[];
|
||||
sourceLists: string[];
|
||||
effectiveAt: string;
|
||||
isNew: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface CountrySanctionsPressure {
|
||||
countryCode: string;
|
||||
countryName: string;
|
||||
entryCount: number;
|
||||
newEntryCount: number;
|
||||
vesselCount: number;
|
||||
aircraftCount: number;
|
||||
}
|
||||
|
||||
export interface ProgramSanctionsPressure {
|
||||
program: string;
|
||||
entryCount: number;
|
||||
newEntryCount: number;
|
||||
}
|
||||
|
||||
export type SanctionsEntityType = "SANCTIONS_ENTITY_TYPE_UNSPECIFIED" | "SANCTIONS_ENTITY_TYPE_ENTITY" | "SANCTIONS_ENTITY_TYPE_INDIVIDUAL" | "SANCTIONS_ENTITY_TYPE_VESSEL" | "SANCTIONS_ENTITY_TYPE_AIRCRAFT";
|
||||
|
||||
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 SanctionsServiceClientOptions {
|
||||
fetch?: typeof fetch;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SanctionsServiceCallOptions {
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class SanctionsServiceClient {
|
||||
private baseURL: string;
|
||||
private fetchFn: typeof fetch;
|
||||
private defaultHeaders: Record<string, string>;
|
||||
|
||||
constructor(baseURL: string, options?: SanctionsServiceClientOptions) {
|
||||
this.baseURL = baseURL.replace(/\/+$/, "");
|
||||
this.fetchFn = options?.fetch ?? globalThis.fetch;
|
||||
this.defaultHeaders = { ...options?.defaultHeaders };
|
||||
}
|
||||
|
||||
async listSanctionsPressure(req: ListSanctionsPressureRequest, options?: SanctionsServiceCallOptions): Promise<ListSanctionsPressureResponse> {
|
||||
let path = "/api/sanctions/v1/list-sanctions-pressure";
|
||||
const params = new URLSearchParams();
|
||||
if (req.maxItems != null && req.maxItems !== 0) params.set("max_items", String(req.maxItems));
|
||||
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 ListSanctionsPressureResponse;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
155
src/generated/server/worldmonitor/sanctions/v1/service_server.ts
Normal file
155
src/generated/server/worldmonitor/sanctions/v1/service_server.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/sanctions/v1/service.proto
|
||||
|
||||
export interface ListSanctionsPressureRequest {
|
||||
maxItems: number;
|
||||
}
|
||||
|
||||
export interface ListSanctionsPressureResponse {
|
||||
entries: SanctionsEntry[];
|
||||
countries: CountrySanctionsPressure[];
|
||||
programs: ProgramSanctionsPressure[];
|
||||
fetchedAt: string;
|
||||
datasetDate: string;
|
||||
totalCount: number;
|
||||
sdnCount: number;
|
||||
consolidatedCount: number;
|
||||
newEntryCount: number;
|
||||
vesselCount: number;
|
||||
aircraftCount: number;
|
||||
}
|
||||
|
||||
export interface SanctionsEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
entityType: SanctionsEntityType;
|
||||
countryCodes: string[];
|
||||
countryNames: string[];
|
||||
programs: string[];
|
||||
sourceLists: string[];
|
||||
effectiveAt: string;
|
||||
isNew: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface CountrySanctionsPressure {
|
||||
countryCode: string;
|
||||
countryName: string;
|
||||
entryCount: number;
|
||||
newEntryCount: number;
|
||||
vesselCount: number;
|
||||
aircraftCount: number;
|
||||
}
|
||||
|
||||
export interface ProgramSanctionsPressure {
|
||||
program: string;
|
||||
entryCount: number;
|
||||
newEntryCount: number;
|
||||
}
|
||||
|
||||
export type SanctionsEntityType = "SANCTIONS_ENTITY_TYPE_UNSPECIFIED" | "SANCTIONS_ENTITY_TYPE_ENTITY" | "SANCTIONS_ENTITY_TYPE_INDIVIDUAL" | "SANCTIONS_ENTITY_TYPE_VESSEL" | "SANCTIONS_ENTITY_TYPE_AIRCRAFT";
|
||||
|
||||
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 SanctionsServiceHandler {
|
||||
listSanctionsPressure(ctx: ServerContext, req: ListSanctionsPressureRequest): Promise<ListSanctionsPressureResponse>;
|
||||
}
|
||||
|
||||
export function createSanctionsServiceRoutes(
|
||||
handler: SanctionsServiceHandler,
|
||||
options?: ServerOptions,
|
||||
): RouteDescriptor[] {
|
||||
return [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/sanctions/v1/list-sanctions-pressure",
|
||||
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: ListSanctionsPressureRequest = {
|
||||
maxItems: Number(params.get("max_items") ?? "0"),
|
||||
};
|
||||
if (options?.validateRequest) {
|
||||
const bodyViolations = options.validateRequest("listSanctionsPressure", body);
|
||||
if (bodyViolations) {
|
||||
throw new ValidationError(bodyViolations);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.listSanctionsPressure(ctx, body);
|
||||
return new Response(JSON.stringify(result as ListSanctionsPressureResponse), {
|
||||
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,5 +1,6 @@
|
||||
import { getLocationName, type GeoConvergenceAlert } from './geo-convergence';
|
||||
import type { CountryScore } from './country-instability';
|
||||
import { getLatestSanctionsPressure, type SanctionsPressureResult } from './sanctions-pressure';
|
||||
import { getLatestRadiationWatch, type RadiationObservation } from './radiation';
|
||||
import type { CascadeResult, CascadeImpactLevel } from '@/types';
|
||||
import { calculateCII, isInLearningMode } from './country-instability';
|
||||
@@ -8,7 +9,7 @@ import { t } from '@/services/i18n';
|
||||
import type { TheaterPostureSummary } from '@/services/military-surge';
|
||||
|
||||
export type AlertPriority = 'critical' | 'high' | 'medium' | 'low';
|
||||
export type AlertType = 'convergence' | 'cii_spike' | 'cascade' | 'radiation' | 'composite';
|
||||
export type AlertType = 'convergence' | 'cii_spike' | 'cascade' | 'sanctions' | 'radiation' | 'composite';
|
||||
|
||||
export interface UnifiedAlert {
|
||||
id: string;
|
||||
@@ -20,6 +21,7 @@ export interface UnifiedAlert {
|
||||
convergence?: GeoConvergenceAlert;
|
||||
ciiChange?: CIIChangeAlert;
|
||||
cascade?: CascadeAlert;
|
||||
sanctions?: SanctionsAlert;
|
||||
radiation?: RadiationAlert;
|
||||
};
|
||||
location?: { lat: number; lon: number };
|
||||
@@ -45,6 +47,20 @@ export interface CascadeAlert {
|
||||
highestImpact: CascadeImpactLevel;
|
||||
}
|
||||
|
||||
|
||||
export interface SanctionsAlert {
|
||||
countryCode: string;
|
||||
countryName: string;
|
||||
entryCount: number;
|
||||
newEntryCount: number;
|
||||
topProgram: string;
|
||||
topProgramCount: number;
|
||||
vesselCount: number;
|
||||
aircraftCount: number;
|
||||
totalCount: number;
|
||||
datasetDate: number | null;
|
||||
}
|
||||
|
||||
export interface RadiationAlert {
|
||||
siteId: string;
|
||||
siteName: string;
|
||||
@@ -134,6 +150,15 @@ function getPriorityFromConvergence(score: number, typeCount: number): AlertPrio
|
||||
return 'low';
|
||||
}
|
||||
|
||||
|
||||
function getPriorityFromSanctions(data: SanctionsPressureResult): AlertPriority {
|
||||
const leadEntryCount = data.countries[0]?.entryCount ?? 0;
|
||||
if (data.newEntryCount >= 10) return 'critical';
|
||||
if (data.newEntryCount >= 3 || leadEntryCount >= 60) return 'high';
|
||||
if (data.newEntryCount >= 1 || leadEntryCount >= 25) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function getPriorityFromRadiation(observation: RadiationObservation, spikeCount: number): AlertPriority {
|
||||
let score = 0;
|
||||
if (observation.severity === 'spike') score += 4;
|
||||
@@ -238,6 +263,54 @@ export function createCascadeAlert(cascade: CascadeResult): UnifiedAlert | null
|
||||
return addAndMergeAlert(alert);
|
||||
}
|
||||
|
||||
function createSanctionsAlert(): UnifiedAlert | null {
|
||||
const pressure = getLatestSanctionsPressure();
|
||||
if (!pressure || pressure.totalCount === 0) {
|
||||
for (let i = alerts.length - 1; i >= 0; i--) {
|
||||
if (alerts[i]?.type === 'sanctions') alerts.splice(i, 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const leadCountry = [...pressure.countries]
|
||||
.sort((a, b) => b.newEntryCount - a.newEntryCount || b.entryCount - a.entryCount)[0];
|
||||
if (!leadCountry) return null;
|
||||
if (pressure.newEntryCount === 0 && leadCountry.entryCount < 25) return null;
|
||||
|
||||
const leadProgram = [...pressure.programs]
|
||||
.sort((a, b) => b.newEntryCount - a.newEntryCount || b.entryCount - a.entryCount)[0];
|
||||
|
||||
const sanctions: SanctionsAlert = {
|
||||
countryCode: leadCountry.countryCode,
|
||||
countryName: leadCountry.countryName,
|
||||
entryCount: leadCountry.entryCount,
|
||||
newEntryCount: leadCountry.newEntryCount,
|
||||
topProgram: leadProgram?.program || 'Unspecified',
|
||||
topProgramCount: leadProgram?.entryCount || 0,
|
||||
vesselCount: leadCountry.vesselCount,
|
||||
aircraftCount: leadCountry.aircraftCount,
|
||||
totalCount: pressure.totalCount,
|
||||
datasetDate: pressure.datasetDate?.getTime() ?? null,
|
||||
};
|
||||
|
||||
const summary = pressure.newEntryCount > 0
|
||||
? `${pressure.newEntryCount} new OFAC designation${pressure.newEntryCount === 1 ? '' : 's'} detected. Pressure is highest around ${leadCountry.countryName} (${leadCountry.entryCount}), with ${leadProgram?.program || 'unspecified'} leading program activity.`
|
||||
: `${leadCountry.countryName} has ${leadCountry.entryCount} OFAC-linked designations in the current dataset, led by ${leadProgram?.program || 'unspecified'} activity.`;
|
||||
|
||||
return addAndMergeAlert({
|
||||
id: 'sanctions-pressure',
|
||||
type: 'sanctions',
|
||||
priority: getPriorityFromSanctions(pressure),
|
||||
title: pressure.newEntryCount > 0
|
||||
? `Sanctions pressure rising around ${leadCountry.countryName}`
|
||||
: `Persistent sanctions pressure around ${leadCountry.countryName}`,
|
||||
summary,
|
||||
components: { sanctions },
|
||||
countries: [leadCountry.countryCode],
|
||||
timestamp: pressure.fetchedAt,
|
||||
});
|
||||
}
|
||||
|
||||
function getRadiationRank(observation: RadiationObservation): number {
|
||||
const severityRank = observation.severity === 'spike' ? 2 : observation.severity === 'elevated' ? 1 : 0;
|
||||
const confidenceRank = observation.confidence === 'high' ? 2 : observation.confidence === 'medium' ? 1 : 0;
|
||||
@@ -367,7 +440,12 @@ function generateCompositeTitle(a: UnifiedAlert, b: UnifiedAlert): string {
|
||||
}
|
||||
|
||||
if (a.components.convergence || b.components.convergence) {
|
||||
const countryCode = a.countries[0] || b.countries[0];
|
||||
if (a.components.sanctions || b.components.sanctions) {
|
||||
const sanctions = a.components.sanctions || b.components.sanctions;
|
||||
if (sanctions) return `Sanctions pressure: ${sanctions.countryName}`;
|
||||
}
|
||||
|
||||
const countryCode = a.countries[0] || b.countries[0];
|
||||
const location = countryCode ? getCountryDisplayName(countryCode) : t('alerts.multipleRegions');
|
||||
return t('alerts.geoAlert', { location });
|
||||
}
|
||||
@@ -376,6 +454,11 @@ function generateCompositeTitle(a: UnifiedAlert, b: UnifiedAlert): string {
|
||||
return t('alerts.cascadeAlert');
|
||||
}
|
||||
|
||||
if (a.components.sanctions || b.components.sanctions) {
|
||||
const sanctions = a.components.sanctions || b.components.sanctions;
|
||||
if (sanctions) return `Sanctions pressure: ${sanctions.countryName}`;
|
||||
}
|
||||
|
||||
const countryCode = a.countries[0] || b.countries[0];
|
||||
const location = countryCode ? getCountryDisplayName(countryCode) : t('alerts.multipleRegions');
|
||||
return t('alerts.alert', { location });
|
||||
@@ -528,6 +611,7 @@ function updateAlerts(convergenceAlerts: GeoConvergenceAlert[]): void {
|
||||
|
||||
// Check for CII changes (alerts are added internally via addAndMergeAlert)
|
||||
checkCIIChanges();
|
||||
createSanctionsAlert();
|
||||
createRadiationAlert();
|
||||
|
||||
// Sort by timestamp (newest first) and limit to 100
|
||||
@@ -549,6 +633,18 @@ export function calculateStrategicRiskOverview(
|
||||
updateAlerts(convergenceAlerts);
|
||||
|
||||
const ciiRiskScore = calculateCIIRiskScore(ciiScores);
|
||||
const sanctionsPressure = getLatestSanctionsPressure();
|
||||
|
||||
const sanctionsScore = sanctionsPressure
|
||||
? Math.min(
|
||||
10,
|
||||
sanctionsPressure.newEntryCount * 2 +
|
||||
Math.min(4, (sanctionsPressure.countries[0]?.entryCount ?? 0) / 20) +
|
||||
sanctionsPressure.vesselCount * 0.3 +
|
||||
sanctionsPressure.aircraftCount * 0.3
|
||||
)
|
||||
: 0;
|
||||
|
||||
const radiationWatch = getLatestRadiationWatch();
|
||||
const radiationScore = radiationWatch
|
||||
? Math.min(
|
||||
@@ -590,6 +686,7 @@ export function calculateStrategicRiskOverview(
|
||||
infraScore * infraWeight +
|
||||
theaterBoost +
|
||||
breakingBoost +
|
||||
sanctionsScore +
|
||||
radiationScore
|
||||
));
|
||||
|
||||
@@ -605,7 +702,7 @@ export function calculateStrategicRiskOverview(
|
||||
infrastructureIncidents: countInfrastructureIncidents(),
|
||||
compositeScore: composite,
|
||||
trend,
|
||||
topRisks: identifyTopRisks(convergenceAlerts, ciiScores, radiationWatch?.observations ?? []),
|
||||
topRisks: identifyTopRisks(convergenceAlerts, ciiScores, sanctionsPressure, radiationWatch?.observations ?? []),
|
||||
topConvergenceZones: convergenceAlerts
|
||||
.slice(0, 3)
|
||||
.map(a => ({ cellId: a.cellId, lat: a.lat, lon: a.lon, score: a.score })),
|
||||
@@ -662,6 +759,7 @@ function countInfrastructureIncidents(): number {
|
||||
function identifyTopRisks(
|
||||
convergence: GeoConvergenceAlert[],
|
||||
cii: CountryScore[],
|
||||
sanctions: SanctionsPressureResult | null,
|
||||
radiation: RadiationObservation[]
|
||||
): string[] {
|
||||
const risks: string[] = [];
|
||||
@@ -672,6 +770,12 @@ function identifyTopRisks(
|
||||
risks.push(`Convergence: ${location} (score: ${top.score})`);
|
||||
}
|
||||
|
||||
const leadSanctions = sanctions?.countries[0];
|
||||
if (leadSanctions && (sanctions.newEntryCount > 0 || leadSanctions.entryCount >= 25)) {
|
||||
const label = sanctions.newEntryCount > 0 ? 'Sanctions burst' : 'Sanctions pressure';
|
||||
risks.push(`${label}: ${leadSanctions.countryName} (${leadSanctions.entryCount}, +${leadSanctions.newEntryCount} new)`);
|
||||
}
|
||||
|
||||
const strongestRadiation = radiation
|
||||
.filter(observation => observation.severity !== 'normal')
|
||||
.sort((a, b) => getRadiationRank(b) - getRadiationRank(a))[0];
|
||||
|
||||
@@ -74,6 +74,7 @@ const SOURCE_METADATA: Record<DataSourceId, { name: string; requiredForRisk: boo
|
||||
wto_trade: { name: 'WTO Trade Policy', requiredForRisk: false, panelId: 'trade-policy' },
|
||||
supply_chain: { name: 'Supply Chain Intelligence', requiredForRisk: false, panelId: 'supply-chain' },
|
||||
security_advisories: { name: 'Security Advisories', requiredForRisk: false, panelId: 'security-advisories' },
|
||||
sanctions_pressure: { name: 'Sanctions Pressure', requiredForRisk: false, panelId: 'sanctions-pressure' },
|
||||
radiation: { name: 'Radiation Watch', requiredForRisk: false, panelId: 'radiation-watch' },
|
||||
gpsjam: { name: 'GPS/GNSS Interference', requiredForRisk: false, panelId: 'map' },
|
||||
treasury_revenue: { name: 'Treasury Customs Revenue', requiredForRisk: false, panelId: 'trade-policy' },
|
||||
@@ -336,6 +337,7 @@ const INTELLIGENCE_GAP_MESSAGES: Record<DataSourceId, string> = {
|
||||
wto_trade: 'Trade policy intelligence unavailable—WTO data not updating',
|
||||
supply_chain: 'Supply chain disruption status unavailable—chokepoint monitoring offline',
|
||||
security_advisories: 'Government travel advisory data unavailable—security alerts may be missed',
|
||||
sanctions_pressure: 'Structured sanctions pressure unavailable\u2014OFAC designation visibility reduced',
|
||||
radiation: 'Radiation monitoring degraded—EPA RadNet and Safecast observations unavailable',
|
||||
gpsjam: 'GPS/GNSS interference data unavailable—jamming zones undetected',
|
||||
treasury_revenue: 'US Treasury customs revenue data unavailable',
|
||||
|
||||
@@ -22,6 +22,7 @@ const SIGNAL_TYPE_LABELS: Record<SignalType, string> = {
|
||||
satellite_fire: 'satellite fires',
|
||||
radiation_anomaly: 'radiation anomalies',
|
||||
temporal_anomaly: 'anomaly detection',
|
||||
sanctions_pressure: 'sanctions pressure',
|
||||
active_strike: 'active strikes',
|
||||
};
|
||||
|
||||
@@ -34,6 +35,7 @@ const SIGNAL_TYPE_ICONS: Record<SignalType, string> = {
|
||||
satellite_fire: '🔥',
|
||||
radiation_anomaly: '☢️',
|
||||
temporal_anomaly: '📊',
|
||||
sanctions_pressure: '🚫',
|
||||
active_strike: '💥',
|
||||
};
|
||||
|
||||
@@ -291,6 +293,7 @@ class FocalPointDetector {
|
||||
(signals.signalTypes.has('military_vessel') && /navy|naval|ships|fleet|carrier/.test(lower)) ||
|
||||
(signals.signalTypes.has('protest') && /protest|demonstrat|unrest|riot/.test(lower)) ||
|
||||
(signals.signalTypes.has('internet_outage') && /internet|blackout|outage|connectivity/.test(lower)) ||
|
||||
(signals.signalTypes.has('sanctions_pressure') && /sanction|designation|ofac|treasury|embargo|blacklist/.test(lower)) ||
|
||||
(signals.signalTypes.has('radiation_anomaly') && /nuclear|radiation|reactor|contamination|radnet/.test(lower)) ||
|
||||
(signals.signalTypes.has('active_strike') && /strike|attack|bomb|missile|target|hit/.test(lower));
|
||||
})) {
|
||||
|
||||
@@ -41,6 +41,8 @@ export * from './trade';
|
||||
export * from './supply-chain';
|
||||
export * from './radiation';
|
||||
export * from './breaking-news-alerts';
|
||||
export * from './sanctions-pressure';
|
||||
export * from './sanctions-pressure';
|
||||
export * from './daily-market-brief';
|
||||
export * from './stock-analysis-history';
|
||||
export * from './stock-backtest';
|
||||
|
||||
172
src/services/sanctions-pressure.ts
Normal file
172
src/services/sanctions-pressure.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { createCircuitBreaker } from '@/utils';
|
||||
import { getRpcBaseUrl } from '@/services/rpc-client';
|
||||
import { getHydratedData } from '@/services/bootstrap';
|
||||
import {
|
||||
SanctionsServiceClient,
|
||||
type SanctionsEntry as ProtoSanctionsEntry,
|
||||
type SanctionsEntityType as ProtoSanctionsEntityType,
|
||||
type CountrySanctionsPressure as ProtoCountryPressure,
|
||||
type ProgramSanctionsPressure as ProtoProgramPressure,
|
||||
type ListSanctionsPressureResponse,
|
||||
} from '@/generated/client/worldmonitor/sanctions/v1/service_client';
|
||||
|
||||
export type SanctionsEntityType = 'entity' | 'individual' | 'vessel' | 'aircraft';
|
||||
|
||||
export interface SanctionsEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
entityType: SanctionsEntityType;
|
||||
countryCodes: string[];
|
||||
countryNames: string[];
|
||||
programs: string[];
|
||||
sourceLists: string[];
|
||||
effectiveAt: Date | null;
|
||||
isNew: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface CountrySanctionsPressure {
|
||||
countryCode: string;
|
||||
countryName: string;
|
||||
entryCount: number;
|
||||
newEntryCount: number;
|
||||
vesselCount: number;
|
||||
aircraftCount: number;
|
||||
}
|
||||
|
||||
export interface ProgramSanctionsPressure {
|
||||
program: string;
|
||||
entryCount: number;
|
||||
newEntryCount: number;
|
||||
}
|
||||
|
||||
export interface SanctionsPressureResult {
|
||||
fetchedAt: Date;
|
||||
datasetDate: Date | null;
|
||||
totalCount: number;
|
||||
sdnCount: number;
|
||||
consolidatedCount: number;
|
||||
newEntryCount: number;
|
||||
vesselCount: number;
|
||||
aircraftCount: number;
|
||||
countries: CountrySanctionsPressure[];
|
||||
programs: ProgramSanctionsPressure[];
|
||||
entries: SanctionsEntry[];
|
||||
}
|
||||
|
||||
const client = new SanctionsServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) });
|
||||
const breaker = createCircuitBreaker<SanctionsPressureResult>({
|
||||
name: 'Sanctions Pressure',
|
||||
cacheTtlMs: 30 * 60 * 1000,
|
||||
persistCache: true,
|
||||
});
|
||||
|
||||
let latestSanctionsPressureResult: SanctionsPressureResult | null = null;
|
||||
|
||||
const emptyResult: SanctionsPressureResult = {
|
||||
fetchedAt: new Date(0),
|
||||
datasetDate: null,
|
||||
totalCount: 0,
|
||||
sdnCount: 0,
|
||||
consolidatedCount: 0,
|
||||
newEntryCount: 0,
|
||||
vesselCount: 0,
|
||||
aircraftCount: 0,
|
||||
countries: [],
|
||||
programs: [],
|
||||
entries: [],
|
||||
};
|
||||
|
||||
function mapEntityType(value: ProtoSanctionsEntityType): SanctionsEntityType {
|
||||
switch (value) {
|
||||
case 'SANCTIONS_ENTITY_TYPE_INDIVIDUAL':
|
||||
return 'individual';
|
||||
case 'SANCTIONS_ENTITY_TYPE_VESSEL':
|
||||
return 'vessel';
|
||||
case 'SANCTIONS_ENTITY_TYPE_AIRCRAFT':
|
||||
return 'aircraft';
|
||||
default:
|
||||
return 'entity';
|
||||
}
|
||||
}
|
||||
|
||||
function parseEpoch(value: string | number | null | undefined): Date | null {
|
||||
if (value == null) return null;
|
||||
const asNumber = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(asNumber) || asNumber <= 0) return null;
|
||||
return new Date(asNumber);
|
||||
}
|
||||
|
||||
function toEntry(raw: ProtoSanctionsEntry): SanctionsEntry {
|
||||
return {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
entityType: mapEntityType(raw.entityType),
|
||||
countryCodes: raw.countryCodes ?? [],
|
||||
countryNames: raw.countryNames ?? [],
|
||||
programs: raw.programs ?? [],
|
||||
sourceLists: raw.sourceLists ?? [],
|
||||
effectiveAt: parseEpoch(raw.effectiveAt as string | number | undefined),
|
||||
isNew: raw.isNew ?? false,
|
||||
note: raw.note ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function toCountry(raw: ProtoCountryPressure): CountrySanctionsPressure {
|
||||
return {
|
||||
countryCode: raw.countryCode,
|
||||
countryName: raw.countryName,
|
||||
entryCount: raw.entryCount ?? 0,
|
||||
newEntryCount: raw.newEntryCount ?? 0,
|
||||
vesselCount: raw.vesselCount ?? 0,
|
||||
aircraftCount: raw.aircraftCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toProgram(raw: ProtoProgramPressure): ProgramSanctionsPressure {
|
||||
return {
|
||||
program: raw.program,
|
||||
entryCount: raw.entryCount ?? 0,
|
||||
newEntryCount: raw.newEntryCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toResult(response: ListSanctionsPressureResponse): SanctionsPressureResult {
|
||||
return {
|
||||
fetchedAt: parseEpoch(response.fetchedAt as string | number | undefined) || new Date(),
|
||||
datasetDate: parseEpoch(response.datasetDate as string | number | undefined),
|
||||
totalCount: response.totalCount ?? 0,
|
||||
sdnCount: response.sdnCount ?? 0,
|
||||
consolidatedCount: response.consolidatedCount ?? 0,
|
||||
newEntryCount: response.newEntryCount ?? 0,
|
||||
vesselCount: response.vesselCount ?? 0,
|
||||
aircraftCount: response.aircraftCount ?? 0,
|
||||
countries: (response.countries ?? []).map(toCountry),
|
||||
programs: (response.programs ?? []).map(toProgram),
|
||||
entries: (response.entries ?? []).map(toEntry),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchSanctionsPressure(): Promise<SanctionsPressureResult> {
|
||||
const hydrated = getHydratedData('sanctionsPressure') as ListSanctionsPressureResponse | undefined;
|
||||
if (hydrated?.entries?.length || hydrated?.countries?.length || hydrated?.programs?.length) {
|
||||
const result = toResult(hydrated);
|
||||
latestSanctionsPressureResult = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
return breaker.execute(async () => {
|
||||
const response = await client.listSanctionsPressure({
|
||||
maxItems: 30,
|
||||
}, {
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
});
|
||||
const result = toResult(response);
|
||||
latestSanctionsPressureResult = result;
|
||||
return result;
|
||||
}, emptyResult);
|
||||
}
|
||||
|
||||
export function getLatestSanctionsPressure(): SanctionsPressureResult | null {
|
||||
return latestSanctionsPressureResult;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SocialUnrestEvent,
|
||||
AisDisruptionEvent,
|
||||
} from '@/types';
|
||||
import type { CountrySanctionsPressure } from './sanctions-pressure';
|
||||
import type { RadiationObservation } from './radiation';
|
||||
import { getCountryAtCoordinates, getCountryNameByCode, nameToCountryCode, ME_STRIKE_BOUNDS, resolveCountryFromBounds } from './country-geometry';
|
||||
|
||||
@@ -22,7 +23,8 @@ export type SignalType =
|
||||
| 'ais_disruption'
|
||||
| 'satellite_fire' // NASA FIRMS thermal anomalies
|
||||
| 'radiation_anomaly' // Radiation readings meaningfully above local baseline
|
||||
| 'temporal_anomaly' // Baseline deviation alerts
|
||||
| 'temporal_anomaly'
|
||||
| 'sanctions_pressure' // Baseline deviation alerts
|
||||
| 'active_strike' // Iran attack / military conflict events
|
||||
|
||||
export interface GeoSignal {
|
||||
@@ -328,6 +330,36 @@ class SignalAggregator {
|
||||
this.pruneOld();
|
||||
}
|
||||
|
||||
ingestSanctionsPressure(countries: CountrySanctionsPressure[]): void {
|
||||
this.clearSignalType('sanctions_pressure');
|
||||
|
||||
for (const country of countries) {
|
||||
const code = normalizeCountryCode(country.countryCode || country.countryName);
|
||||
const severity: 'low' | 'medium' | 'high' =
|
||||
country.newEntryCount >= 5 || country.entryCount >= 50
|
||||
? 'high'
|
||||
: country.newEntryCount >= 1 || country.entryCount >= 20
|
||||
? 'medium'
|
||||
: 'low';
|
||||
if (country.newEntryCount === 0 && country.entryCount < 20) continue;
|
||||
|
||||
this.signals.push({
|
||||
type: 'sanctions_pressure',
|
||||
country: code,
|
||||
countryName: country.countryName || getCountryName(code),
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
severity,
|
||||
title: country.newEntryCount > 0
|
||||
? `${country.newEntryCount} new OFAC designation${country.newEntryCount === 1 ? '' : 's'} tied to ${country.countryName}`
|
||||
: `${country.entryCount} OFAC-linked designations tied to ${country.countryName}`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
this.pruneOld();
|
||||
}
|
||||
|
||||
|
||||
ingestConflictEvents(events: Array<{
|
||||
id: string;
|
||||
category: string;
|
||||
@@ -504,6 +536,7 @@ class SignalAggregator {
|
||||
satellite_fire: 'thermal anomalies',
|
||||
radiation_anomaly: 'radiation anomalies',
|
||||
temporal_anomaly: 'baseline anomalies',
|
||||
sanctions_pressure: 'sanctions pressure',
|
||||
active_strike: 'active strikes',
|
||||
};
|
||||
|
||||
@@ -561,6 +594,7 @@ class SignalAggregator {
|
||||
satellite_fire: 0,
|
||||
radiation_anomaly: 0,
|
||||
temporal_anomaly: 0,
|
||||
sanctions_pressure: 0,
|
||||
active_strike: 0,
|
||||
};
|
||||
|
||||
|
||||
@@ -8554,6 +8554,20 @@ a.prediction-link:hover {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
.sanctions-panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sanctions-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.radiation-panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -8567,6 +8581,15 @@ a.prediction-link:hover {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.sanctions-summary-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--overlay-subtle);
|
||||
}
|
||||
|
||||
.radiation-summary-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -8576,6 +8599,11 @@ a.prediction-link:hover {
|
||||
background: var(--overlay-subtle);
|
||||
}
|
||||
|
||||
.sanctions-summary-card-highlight {
|
||||
border-color: rgba(245, 158, 11, 0.35);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
|
||||
.radiation-summary-card-spike {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
@@ -8591,11 +8619,25 @@ a.prediction-link:hover {
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
|
||||
.sanctions-summary-card-muted {
|
||||
border-color: rgba(125, 211, 252, 0.35);
|
||||
background: rgba(125, 211, 252, 0.08);
|
||||
}
|
||||
|
||||
.radiation-summary-card-conflict {
|
||||
border-color: rgba(125, 211, 252, 0.35);
|
||||
background: rgba(125, 211, 252, 0.08);
|
||||
}
|
||||
|
||||
.sanctions-summary-label,
|
||||
.sanctions-headline-label,
|
||||
.sanctions-section-title {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.radiation-summary-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
@@ -8603,12 +8645,79 @@ a.prediction-link:hover {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.sanctions-summary-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.radiation-summary-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sanctions-headlines,
|
||||
.sanctions-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.sanctions-headline,
|
||||
.sanctions-section {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.sanctions-headline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.sanctions-headline-value {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sanctions-section-title {
|
||||
padding: 8px 10px 0;
|
||||
}
|
||||
|
||||
.sanctions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sanctions-row,
|
||||
.sanctions-entry {
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sanctions-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sanctions-row-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sanctions-row-title,
|
||||
.sanctions-entry-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.radiation-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -8641,12 +8750,45 @@ a.prediction-link:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sanctions-row-meta,
|
||||
.sanctions-entry-meta,
|
||||
.sanctions-entry-note {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.radiation-location-meta {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.sanctions-row-flags,
|
||||
.sanctions-entry-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sanctions-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--overlay-subtle);
|
||||
}
|
||||
|
||||
.sanctions-pill-new {
|
||||
color: var(--semantic-elevated);
|
||||
border-color: rgba(245, 158, 11, 0.35);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
|
||||
.radiation-location-flags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -8681,6 +8823,10 @@ a.prediction-link:hover {
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
|
||||
.sanctions-pill-type {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.radiation-confidence-low {
|
||||
color: #7dd3fc;
|
||||
border-color: rgba(125, 211, 252, 0.35);
|
||||
|
||||
@@ -30,6 +30,7 @@ export type DataSourceId =
|
||||
| 'supply_chain'
|
||||
| 'security_advisories'
|
||||
| 'gpsjam'
|
||||
| 'sanctions_pressure'
|
||||
| 'radiation'
|
||||
| 'treasury_revenue';
|
||||
|
||||
|
||||
148
tests/sanctions-pressure.test.mjs
Normal file
148
tests/sanctions-pressure.test.mjs
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const handlerSrc = readFileSync('server/worldmonitor/sanctions/v1/list-sanctions-pressure.ts', 'utf8');
|
||||
const seedSrc = readFileSync('scripts/seed-sanctions-pressure.mjs', 'utf8');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P2-1: _state must not leak through trimResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('trimResponse: _state stripping', () => {
|
||||
it('handler trimResponse destructures _state before spreading data', () => {
|
||||
assert.match(
|
||||
handlerSrc,
|
||||
/_state.*_discarded.*\.\.\.rest/s,
|
||||
'trimResponse must destructure _state out before spreading to prevent leaking seed internals to API clients',
|
||||
);
|
||||
});
|
||||
|
||||
it('seed does not embed _state in the canonical Redis payload directly', () => {
|
||||
// The canonical payload must go through extraKeys or afterPublish, not inline
|
||||
const fetchFnStart = seedSrc.indexOf('async function fetchSanctionsPressure()');
|
||||
const fetchFnEnd = seedSrc.indexOf('\nfunction validate(');
|
||||
const fetchFnBody = seedSrc.slice(fetchFnStart, fetchFnEnd);
|
||||
// _state must only appear as a separate top-level key, not inside entries/countries/programs
|
||||
assert.match(
|
||||
fetchFnBody,
|
||||
/_state:\s*\{/,
|
||||
'fetchSanctionsPressure must return _state as a top-level key for extraKeys separation',
|
||||
);
|
||||
// Verify extraKeys is wired to write _state to its own Redis key
|
||||
assert.match(
|
||||
seedSrc,
|
||||
/extraKeys.*STATE_KEY/s,
|
||||
'extraKeys must reference STATE_KEY to write _state separately from canonical payload',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P2-2: buildLocationMap must sort code/name as aligned pairs
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('buildLocationMap: code/name alignment', () => {
|
||||
it('handler buildLocationMap uses paired sort instead of independent uniqueSorted calls', () => {
|
||||
const fnStart = handlerSrc.indexOf('function buildLocationMap(');
|
||||
const fnEnd = handlerSrc.indexOf('\nfunction extractPartyName(');
|
||||
const fnBody = handlerSrc.slice(fnStart, fnEnd);
|
||||
|
||||
assert.match(
|
||||
fnBody,
|
||||
/new Map\(mapped\.map/,
|
||||
'buildLocationMap must deduplicate via Map keyed on code to preserve alignment',
|
||||
);
|
||||
assert.match(
|
||||
fnBody,
|
||||
/pairs\.map\(\(\[code\]\)/,
|
||||
'buildLocationMap must derive codes from sorted pairs array',
|
||||
);
|
||||
assert.match(
|
||||
fnBody,
|
||||
/pairs\.map\(\(\[, name\]\)/,
|
||||
'buildLocationMap must derive names from sorted pairs array',
|
||||
);
|
||||
// Must NOT independently sort codes and names
|
||||
assert.ok(
|
||||
!fnBody.includes('uniqueSorted(mapped.map((item) => item.code))'),
|
||||
'buildLocationMap must not call uniqueSorted on codes independently',
|
||||
);
|
||||
assert.ok(
|
||||
!fnBody.includes('uniqueSorted(mapped.map((item) => item.name))'),
|
||||
'buildLocationMap must not call uniqueSorted on names independently',
|
||||
);
|
||||
});
|
||||
|
||||
it('seed buildLocationMap uses paired sort instead of independent uniqueSorted calls', () => {
|
||||
const fnStart = seedSrc.indexOf('function buildLocationMap(');
|
||||
const fnEnd = seedSrc.indexOf('\nfunction extractPartyName(');
|
||||
const fnBody = seedSrc.slice(fnStart, fnEnd);
|
||||
|
||||
assert.match(
|
||||
fnBody,
|
||||
/new Map\(mapped\.map/,
|
||||
'seed buildLocationMap must deduplicate via Map keyed on code',
|
||||
);
|
||||
assert.ok(
|
||||
!fnBody.includes("uniqueSorted(mapped.map((item) => item.code))"),
|
||||
'seed buildLocationMap must not sort codes independently',
|
||||
);
|
||||
assert.ok(
|
||||
!fnBody.includes("uniqueSorted(mapped.map((item) => item.name))"),
|
||||
'seed buildLocationMap must not sort names independently',
|
||||
);
|
||||
});
|
||||
|
||||
it('handler extractPartyCountries deduplicates via Map instead of independent uniqueSorted', () => {
|
||||
const fnStart = handlerSrc.indexOf('function extractPartyCountries(');
|
||||
const fnEnd = handlerSrc.indexOf('\nfunction buildPartyMap(');
|
||||
const fnBody = handlerSrc.slice(fnStart, fnEnd);
|
||||
|
||||
assert.match(
|
||||
fnBody,
|
||||
/const seen = new Map/,
|
||||
'extractPartyCountries must use a seen Map for deduplication',
|
||||
);
|
||||
assert.ok(
|
||||
!fnBody.includes('uniqueSorted(codes)'),
|
||||
'extractPartyCountries must not sort codes independently via uniqueSorted',
|
||||
);
|
||||
assert.ok(
|
||||
!fnBody.includes('uniqueSorted(names)'),
|
||||
'extractPartyCountries must not sort names independently via uniqueSorted',
|
||||
);
|
||||
});
|
||||
|
||||
it('seed extractPartyCountries deduplicates via Map instead of independent uniqueSorted', () => {
|
||||
const fnStart = seedSrc.indexOf('function extractPartyCountries(');
|
||||
const fnEnd = seedSrc.indexOf('\nfunction buildPartyMap(');
|
||||
const fnBody = seedSrc.slice(fnStart, fnEnd);
|
||||
|
||||
assert.match(
|
||||
fnBody,
|
||||
/const seen = new Map/,
|
||||
'seed extractPartyCountries must use a seen Map for deduplication',
|
||||
);
|
||||
assert.ok(
|
||||
!fnBody.includes('uniqueSorted(codes)'),
|
||||
'seed extractPartyCountries must not sort codes independently',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P3: DEFAULT_RECENT_LIMIT must not exceed MAX_ITEMS_LIMIT
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('sanctions seed: DEFAULT_RECENT_LIMIT vs MAX_ITEMS_LIMIT', () => {
|
||||
it('seed DEFAULT_RECENT_LIMIT does not exceed handler MAX_ITEMS_LIMIT (60)', () => {
|
||||
const match = seedSrc.match(/const DEFAULT_RECENT_LIMIT\s*=\s*(\d+)/);
|
||||
assert.ok(match, 'DEFAULT_RECENT_LIMIT must be defined in seed script');
|
||||
const seedLimit = Number(match[1]);
|
||||
const handlerMatch = handlerSrc.match(/const MAX_ITEMS_LIMIT\s*=\s*(\d+)/);
|
||||
assert.ok(handlerMatch, 'MAX_ITEMS_LIMIT must be defined in handler');
|
||||
const handlerLimit = Number(handlerMatch[1]);
|
||||
assert.ok(
|
||||
seedLimit <= handlerLimit,
|
||||
`DEFAULT_RECENT_LIMIT (${seedLimit}) must not exceed MAX_ITEMS_LIMIT (${handlerLimit}): entries above the handler limit are never served`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user