Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions proto/worldmonitor/sanctions/v1/list_sanctions_pressure.proto
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ 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" }];
// Optional time range filter (e.g. "1h", "6h", "24h", "48h", "7d").
// When set, entries are filtered to those with effective_at within
// the window and counts are recomputed accordingly.
string time_range = 2 [(sebuf.http.query) = { name: "time_range" }];
}

// ListSanctionsPressureResponse contains normalized OFAC pressure summaries and recent entries.
Expand Down
80 changes: 80 additions & 0 deletions server/worldmonitor/sanctions/v1/list-sanctions-pressure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
ListSanctionsPressureRequest,
ListSanctionsPressureResponse,
SanctionsServiceHandler,
SanctionsEntry,
ServerContext,
} from '../../../../src/generated/server/worldmonitor/sanctions/v1/service_server';

Expand All @@ -11,6 +12,14 @@ const REDIS_CACHE_KEY = 'sanctions:pressure:v1';
const DEFAULT_MAX_ITEMS = 25;
const MAX_ITEMS_LIMIT = 60;

const TIME_RANGE_MS: Record<string, number> = {
'1h': 60 * 60 * 1000,
'6h': 6 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'48h': 48 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
};

// All fetch/parse/scoring logic lives in the Railway seed script
// (scripts/seed-sanctions-pressure.mjs). This handler reads pre-built
// data from Redis only (gold standard: Vercel reads, Railway writes).
Expand All @@ -36,6 +45,72 @@ function emptyResponse(): ListSanctionsPressureResponse {
};
}

/**
* When a time_range is supplied (e.g. "7d"), recompute newEntryCount and
* per-country / per-program counts so they reflect only entries whose
* effectiveAt falls within the requested window.
*/
function applyTimeRangeFilter(
data: ListSanctionsPressureResponse,
timeRange: string,
maxItems: number,
): ListSanctionsPressureResponse {
const windowMs = TIME_RANGE_MS[timeRange];
if (!windowMs) {
// Unknown or 'all' — return unfiltered (existing behaviour)
return { ...data, entries: (data.entries ?? []).slice(0, maxItems) };
}

const cutoff = Date.now() - windowMs;
const allEntries = data.entries ?? [];

// Mark entries whose effectiveAt falls within the window as "new"
// and recompute the global / per-country / per-program counts.
const retagged: SanctionsEntry[] = allEntries.map((e) => {
const ts = Number(e.effectiveAt);
const withinWindow = Number.isFinite(ts) && ts > 0 && ts >= cutoff;
return { ...e, isNew: withinWindow };
});

const newEntryCount = retagged.filter((e) => e.isNew).length;

// Start from the original countries/programs (preserving every entry),
// then patch newEntryCount with recomputed values from the time window.
const countryNewCounts = new Map<string, number>();
for (const entry of retagged) {
if (!entry.isNew) continue;
for (const code of (entry.countryCodes ?? [])) {
countryNewCounts.set(code, (countryNewCounts.get(code) ?? 0) + 1);
}
}
const countries = (data.countries ?? []).map((c) => ({
...c,
newEntryCount: countryNewCounts.get(c.countryCode) ?? 0,
}));

const programNewCounts = new Map<string, number>();
for (const entry of retagged) {
if (!entry.isNew) continue;
for (const prog of (entry.programs ?? [])) {
programNewCounts.set(prog, (programNewCounts.get(prog) ?? 0) + 1);
}
}
const programs = (data.programs ?? []).map((p) => ({
...p,
newEntryCount: programNewCounts.get(p.program) ?? 0,
}));

return {
...data,
entries: retagged.slice(0, maxItems),
countries,
programs,
newEntryCount,
vesselCount: data.vesselCount,
aircraftCount: data.aircraftCount,
};
}

export const listSanctionsPressure: SanctionsServiceHandler['listSanctionsPressure'] = async (
_ctx: ServerContext,
req: ListSanctionsPressureRequest,
Expand All @@ -45,6 +120,11 @@ export const listSanctionsPressure: SanctionsServiceHandler['listSanctionsPressu
const data = await getCachedJson(REDIS_CACHE_KEY, true) as ListSanctionsPressureResponse & { _state?: unknown } | null;
if (!data?.totalCount) return emptyResponse();
const { _state: _discarded, ...rest } = data;

if (req.timeRange) {
return applyTimeRangeFilter(rest, req.timeRange, maxItems);
}

return {
...rest,
entries: (data.entries ?? []).slice(0, maxItems),
Expand Down
5 changes: 5 additions & 0 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,11 @@ export class App {
loadAllData: () => this.dataLoader.loadAllData(),
updateMonitorResults: () => this.dataLoader.updateMonitorResults(),
loadSecurityAdvisories: () => this.dataLoader.loadSecurityAdvisories(),
onTimeRangeChanged: () => {
if (this.state.panels['sanctions-pressure']) {
void this.dataLoader.loadSanctionsPressure();
}
},
});

this.eventHandlers = new EventHandlerManager(this.state, {
Expand Down
2 changes: 1 addition & 1 deletion src/app/data-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3108,7 +3108,7 @@ export class DataLoaderManager implements AppModule {

async loadSanctionsPressure(): Promise<void> {
try {
const result = await fetchSanctionsPressure();
const result = await fetchSanctionsPressure(this.ctx.currentTimeRange);
this.callPanel('sanctions-pressure', 'setData', result);
this.ctx.intelligenceCache.sanctions = result;
signalAggregator.ingestSanctionsPressure(result.countries);
Expand Down
2 changes: 2 additions & 0 deletions src/app/panel-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export interface PanelLayoutManagerCallbacks {
loadAllData: () => Promise<void>;
updateMonitorResults: () => void;
loadSecurityAdvisories?: () => Promise<void>;
onTimeRangeChanged?: (timeRange: string) => void;
}

export class PanelLayoutManager implements AppModule {
Expand All @@ -141,6 +142,7 @@ export class PanelLayoutManager implements AppModule {
this.callbacks = callbacks;
this.applyTimeRangeFilterDebounced = debounce(() => {
this.applyTimeRangeFilterToNewsPanels();
this.callbacks.onTimeRangeChanged?.(ctx.currentTimeRange);
}, 120);

// Dodo Payments: entitlement subscription + billing watch for ALL users.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

export interface ListSanctionsPressureRequest {
maxItems: number;
timeRange?: string;
}

export interface ListSanctionsPressureResponse {
Expand Down Expand Up @@ -121,6 +122,7 @@ export class SanctionsServiceClient {
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));
if (req.timeRange != null && req.timeRange !== "") params.set("time_range", String(req.timeRange));
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");

const headers: Record<string, string> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

export interface ListSanctionsPressureRequest {
maxItems: number;
timeRange?: string;
}

export interface ListSanctionsPressureResponse {
Expand Down Expand Up @@ -133,6 +134,7 @@ export function createSanctionsServiceRoutes(
const params = url.searchParams;
const body: ListSanctionsPressureRequest = {
maxItems: Number(params.get("max_items") ?? "0"),
timeRange: params.get("time_range") ?? "",
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("listSanctionsPressure", body);
Expand All @@ -148,9 +150,13 @@ export function createSanctionsServiceRoutes(
};

const result = await handler.listSanctionsPressure(ctx, body);
const responseHeaders: Record<string, string> = { "Content-Type": "application/json" };
// Filtered requests use Date.now() for the cutoff, so the response
// must not be served from CDN cache (s-maxage would freeze the cutoff).
if (body.timeRange) responseHeaders["X-No-Cache"] = "1";
return new Response(JSON.stringify(result as ListSanctionsPressureResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
headers: responseHeaders,
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
Expand Down
37 changes: 26 additions & 11 deletions src/services/sanctions-pressure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,19 @@ export interface SanctionsPressureResult {
}

const client = new SanctionsServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) });

// Two separate breakers so that failures on filtered requests (e.g. 7d)
// don't put the canonical unfiltered view into cooldown, and vice-versa.
const breaker = createCircuitBreaker<SanctionsPressureResult>({
name: 'Sanctions Pressure',
cacheTtlMs: 30 * 60 * 1000,
persistCache: true,
});
const filteredBreaker = createCircuitBreaker<SanctionsPressureResult>({
name: 'Sanctions Pressure (filtered)',
cacheTtlMs: 10 * 60 * 1000,
persistCache: false,
});

let latestSanctionsPressureResult: SanctionsPressureResult | null = null;

Expand Down Expand Up @@ -147,30 +155,37 @@ function toResult(response: ListSanctionsPressureResponse): SanctionsPressureRes
};
}

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;
export async function fetchSanctionsPressure(timeRange?: string): Promise<SanctionsPressureResult> {
// Only use the bootstrap hydration path when there is no timeRange filter:
// hydrated data carries the seed script's static isNew flags and cannot be
// re-filtered, so a non-default window would show incorrect counts.
if (!timeRange) {
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 activeBreaker = timeRange ? filteredBreaker : breaker;
const cacheKey = timeRange || 'all';

return activeBreaker.execute(async () => {
const response = await client.listSanctionsPressure({
maxItems: 30,
timeRange,
}, {
signal: AbortSignal.timeout(25_000),
});
const result = toResult(response);
latestSanctionsPressureResult = result;
if (result.totalCount === 0) {
// Seed is missing or the feed is down. Evict any stale cache so the
// panel surfaces "unavailable" instead of serving old designations
// indefinitely via stale-while-revalidate.
breaker.clearCache();
activeBreaker.clearCache(cacheKey);
}
return result;
}, emptyResult, {
cacheKey,
shouldCache: (result) => result.totalCount > 0,
});
}
Expand Down