Skip to content

feat(alertRules): country-scoping — schema + mutation + relay filter + UI picker#3632

Open
koala73 wants to merge 3 commits into
mainfrom
feat/alert-rules-countries-scope
Open

feat(alertRules): country-scoping — schema + mutation + relay filter + UI picker#3632
koala73 wants to merge 3 commits into
mainfrom
feat/alert-rules-countries-scope

Conversation

@koala73
Copy link
Copy Markdown
Owner

@koala73 koala73 commented May 9, 2026

Summary

Adds an optional countries field to alertRules so users can scope notifications to specific countries (e.g. only alert me about events in Iran or Saudi Arabia). Empty/absent → all countries (today's behavior; backward-compatible).

Independent PR — not stacked on the followed-countries primitive (#3621/#3629/#3631). Branches directly off main. Smart-defaults the country picker UI from PR A's getFollowed() helper IF available at runtime via a window-registry pattern (no static or dynamic import), but ships fully functional without PR A.

What ships

5 layers:

  1. Convex schema (convex/schema.ts)countries: v.optional(v.array(v.string())) on alertRules. No migration; existing rows treated as "all countries".

  2. Convex mutations (convex/alertRules.ts)setAlertRules, setAlertRulesForUser, setNotificationConfigForUser accept countries arg via shared normalizeCountries(). Trim/uppercase/regex (^[A-Z]{2}$)/dedupe. Cap-50 anti-abuse ceiling → ConvexError({kind: 'COUNTRIES_LIMIT_EXCEEDED'}). Preserve-on-omit; explicit [] resets to "all". Shape-only validation (NOT ISO-3166 registry) — keeps independent of PR A's convex/lib/iso2.ts.

  3. HTTP / API forwarding (convex/http.ts, api/notification-channels.ts, src/services/notification-channels.ts)countries flows through the full HTTP → API → mutation chain.

  4. Relay filter (scripts/notification-relay.cjs) — new eventMatchesCountryScope(event, rule) helper in the per-rule eligibility filter. Country attribution priority: event.payload.countryCodeevent.payload.countryevent.country (publishers are inconsistent; precedent set by regional-snapshot, ais-relay, rss_alert). PERMISSIVE semantics for unattributed events: events with no country attribution (or malformed values like 'USA' / 'United States') ARE delivered to country-scoped rules. Strict semantics still apply when the event IS attributed but doesn't match. Documented inline.

    Why permissive on unattributed? Most current publishers don't emit a country code. Strict drop-on-missing would mean a user setting countries=['US'] gets ZERO alerts — strictly worse UX than "occasional unscoped global event slips through." A user opting into country-scope expects to receive AT LEAST events from those countries; permissive delivery on unattributed events meets that expectation. As publishers add country attribution (planned follow-up audit), scoped delivery tightens automatically. A future strict-mode opt-in (e.g. rule.countriesStrict=true) is left to a follow-up UI surface.

  5. UI picker (src/utils/country-chip-picker.ts + src/services/notifications-settings.ts) — multi-select chip picker with curated 20-country short-list + type-to-add 2-letter input for the long tail. Smart-default seed via loadFollowedCountriesSafe() — synchronous read from window.__wmFollowedCountries?.getFollowed(). PR A's followed-countries module (when it ships) self-registers on window.__wmFollowedCountries; this picker reads if-present, returns [] if absent. Smart-default ONLY applies on NEW-rule create; editing existing rules respects stored countries. Centralized save state via getCurrentAlertRuleFormState() so every alertRules save path threads countries through.

Test plan

  • npm run typecheck — clean
  • npm run typecheck:api — clean
  • npm run test:convex225 pass
  • npm run test:data8312 pass (+ ~52 new across the three layers)
  • vite build — succeeds (verified the window-registry approach has no Vite alias coupling)

New tests (highlights):

  • 8 convex mutation tests: shape validation, normalization (lowercase, whitespace, dedupe), preserve-on-omit, explicit-reset-to-empty, cap-50, backward compat.
  • 18 relay filter tests: empty/all, US/GB match, no-attribution-permissive-deliver, empty-string-permissive, whitespace-permissive, payload field priority, lowercase normalization, malformed-country-permissive, attributed-mismatch-strict-drop.
  • 26 picker tests: render shape, smart-default new vs edit, preselectCountry precedence, loadFollowedCountriesSafe window-registry behavior (absent / present / throws / non-array / missing-method), save-path centralization assertions (every saveAlertRules call site spreads ...state), normalizeIso2, type-to-add, dedup, cap.

Key design decisions

  • Permissive-on-unattributed relay semantics — when a rule sets countries = ['US'], an event with no country attribution IS delivered. Strict semantics still apply when the event IS attributed but doesn't match. Errs on over-delivery (occasional global event slips through) over under-delivery (zero-alert silent failure).
  • Centralized save state via getCurrentAlertRuleFormState() — every alertRules save path (picker debounce, channel connect, enable toggle, sensitivity, AI digest toggle) sources its full payload from one helper. Catches future drift: any new save path that hand-rolls its own payload is flagged by the source-grep test.
  • Window-registry smart-defaultloadFollowedCountriesSafe() reads from window.__wmFollowedCountries?.getFollowed(). PR A self-registers in its own module-init block when it eventually merges; PR feat(alertRules): country-scoping — schema + mutation + relay filter + UI picker #3632 reads-if-present. No Vite alias coupling, no @vite-ignore tricks, no dynamic import that breaks in production browser builds.
  • Shape validation, not registry validation'XX' passes the regex but no event will match it; keeps this PR independent of PR A's canonical alpha-2 list.
  • Cap at 50 — defensive ceiling against patched-client abuse (~250 ISO countries; nobody legitimately wants 100+).
  • Three mutations updated, not just setAlertRulessetAlertRulesForUser (HTTP path) and setNotificationConfigForUser (atomic delivery-mode flow) also accept the field; without those, the countries arg would only persist via the direct-mutation path.

Unlocks

  • PR B's U8 R9 pre-fill (feat(followed-countries): consumer wiring PR B — CII pin + filter chip + deep-dive notify #3629). The "Notify me about this country" sub-action on the deep-dive can now pass the viewed country to the notifications create form and have it pre-checked. Three TODO injection points are already marked in PR B's src/utils/notify-country-link.ts — wire-swap once both PRs land.
  • Per-country digest emails (future PR). Once notifications are country-scoped, per-country digest aggregation becomes a natural follow-up.
  • PR A integration — when PR A merges, a tiny follow-up adds if (typeof window !== 'undefined') { (window as any).__wmFollowedCountries = { getFollowed }; } to PR A's followed-countries module. This PR's smart-default activates automatically.

Out of scope

  • Digest cron country filter. This PR only filters the realtime fan-out path. The digest aggregation runs from a separate per-user cache; country-scoping there is a future PR.
  • countFollowers rate limit (still open from PR A). Vercel edge wrapper.
  • Strict-mode opt-in for country-scoped rules. Today's relay is permissive on unattributed events. A future UI surface (rule.countriesStrict=true) can opt users into strict drop-on-missing once publisher attribution coverage is high enough.
  • alertRules schema deploy ordering safety net (memory: convex-schema-deployment-mismatch). Convex auto-deploys on merge; the schema field is optional so handlers deploy in the same push without race risk.

… + relay filter + UI picker

Adds an optional 'countries' field to alertRules so users can scope
notifications to specific countries (e.g. 'only alert me about
events in Iran or Saudi Arabia'). Empty/absent → all countries
(today's behavior; backward-compatible).

INDEPENDENT PR — does not stack on PR A/B/C of the followed-
countries primitive. Smart-defaults the country picker UI from
PR A's getFollowed() helper IF available at runtime (dynamic
import + try/catch graceful degradation), but ships fully
functional without PR A.

5 layers:

1. convex/schema.ts: add countries: v.optional(v.array(v.string())).
   No migration; existing rows without the field treated as 'all'.

2. convex/alertRules.ts: setAlertRules + setAlertRulesForUser +
   setNotificationConfigForUser accept countries arg via shared
   normalizeCountries() helper (trim/uppercase/regex/dedupe, cap-50
   anti-abuse). Preserve-on-omit; explicit [] resets to 'all'.
   Throws structured COUNTRIES_LIMIT_EXCEEDED ConvexError on cap.
   Shape-only validation (regex), NOT registry validation — keeps
   this PR independent of PR A's convex/lib/iso2.ts.

3. convex/http.ts + api/notification-channels.ts +
   src/services/notification-channels.ts: forward countries through
   the full HTTP / API / mutation chain so external callers can
   set it via the existing /api/notification-channels POST endpoint.

4. scripts/notification-relay.cjs: eventMatchesCountryScope(event,
   rule) helper added to the per-rule eligibility filter. Country
   attribution priority: payload.countryCode → payload.country →
   event.country (publishers are inconsistent; precedent set by
   regional-snapshot, ais-relay, rss_alert).
   STRICT semantics documented inline: events with no country
   attribution OR malformed country codes (e.g. 'USA',
   'United States') are NOT delivered to country-scoped rules.
   Errs on under-delivery (recoverable) over over-delivery (which
   trains users to ignore notifications).

5. src/utils/country-chip-picker.ts (new) + notifications-settings.ts:
   Multi-select chip picker with curated 20-country short-list +
   type-to-add 2-letter input for the long tail. Smart-default seed
   via dynamic-import-safe loadFollowedCountriesSafe() — wraps the
   import in /* @vite-ignore */ + string-variable indirection so
   Vite doesn't statically resolve the path; failed import returns
   [] gracefully. Smart-default ONLY applies on NEW-rule create;
   editing existing rules respects stored countries even after PR A
   ships. Hint copy: 'Leave empty to receive alerts from all
   countries.'

41 new tests:
- 8 convex mutation tests: shape validation, normalization,
  preserve-on-omit, explicit-reset-to-empty, cap-50, backward compat.
- 15 relay filter tests: empty/all, US/GB match, no-attribution
  strict-no-deliver, payload field priority, lowercase normalization,
  malformed-country rejection, source-grep contract checks.
- 18 picker tests: render shape, smart-default new vs edit,
  loadFollowedCountriesSafe graceful degradation, normalizeIso2,
  type-to-add, dedup, cap, source-grep contract checks.

Test totals:
- npm run test:convex: 217 → 225 (+8)
- npm run test:data: 8256 → 8289 (+33)
- npm run typecheck + typecheck:api: clean
- vite build: succeeds (despite PR A's followed-countries module
  not existing on this branch — verified the dynamic-import dodge
  works correctly).

Unlocks: PR B's U8 R9 pre-fill (the 'Notify me about this country'
sub-action can now pass the country code to the notifications form
and have it pre-checked in the new picker). Three TODO injection
points marked in PR B's notify-country-link.ts can now be wired
swap-in-place when this PR + PR B both land.

PR C complement: the bias multiplier (PR C) and country-scoped alerts
(this PR) are orthogonal — bias affects which threads appear in the
brief; alert rules trigger out-of-band notifications. Together they
let a user say 'tell me about Iran via daily brief AND alert me
critical Iran events instantly via Telegram.'
@vercel
Copy link
Copy Markdown

vercel Bot commented May 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment May 9, 2026 9:21am

Request Review

…U8 R9 pre-fill end-to-end

Adds the receiver half of PR B's U8 R9 'Notify me about this country'
sub-action. After this PR + PR B both merge, a tiny one-line update
to event-handlers.ts (forward detail.country to the open call) makes
the pre-fill work end-to-end.

NotificationsSettingsHost.preselectCountry?: string accepts an
ISO-3166 alpha-2 code. Defensive normalization via regex at the
entry point (^[A-Z]{2}$); silently dropped if invalid.

Three-way precedence on NEW rules:
  1. preselectCountry (deep-dive R9 click)  — highest priority
  2. loadFollowedCountriesSafe() smart-default (PR A watchlist)
  3. [] fallback (all countries; current behavior)

(1) wins because the user explicitly clicked from a country deep-
dive INTO this form — that country should pre-check even if it's
not in their watchlist.

Existing rules (existingRule !== null) ignore preselectCountry
entirely. The user's stored countries[] is authoritative for edit-
existing flows. Verified by source-grep test.

4 new tests:
- preselectCountry?: string declared on the host interface
- normalizePreselectCountry helper validates ^[A-Z]{2}$ shape
- preselectCountry precedence over loadFollowedCountriesSafe on NEW
- preselectCountry does NOT touch existing-rule path (edit-respect)

Tests: 18 → 22 (+4). typecheck + typecheck:api clean.

Plan: U8 R9 receiver (PR B unlock).
Follow-up after both PRs land: one-line update in
src/app/event-handlers.ts to pass detail.country as preselectCountry
when opening unifiedSettings.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 9, 2026

Greptile Summary

This PR adds optional country-scoping to alertRules so users can restrict realtime notifications to specific countries. It touches all five layers — Convex schema, three mutations, the HTTP/API forwarding chain, the notification relay filter, and a new UI chip picker.

  • Schema + mutations: countries: v.optional(v.array(v.string())) added to the alertRules table; a shared normalizeCountries() helper applies trim/uppercase/regex/dedupe/cap-50 consistently across all three updated mutations with correct preserve-on-omit semantics.
  • Relay filter: New eventMatchesCountryScope() function with well-documented strict-no-attribution semantics and correct payload.countryCode → payload.country → event.country extraction priority, wired into the per-rule realtime matching loop.
  • UI picker: Self-contained mountCountryChipPicker with a 20-country short-list, type-to-add input, and a graceful-degradation dynamic import for the optional getFollowed() smart-default.

Confidence Score: 3/5

Safe to merge after fixing the missing abort-signal guard in the picker mount path; all other layers are solid.

The abort-signal gap in notifications-settings.ts is a real functional defect on the new-rule path: when loadFollowedCountriesSafe resolves after the settings panel has been closed, a stale countryPicker handle is written back to the outer scope and the debounced onChange can fire a ghost mutation for a rule the user no longer sees. The relay filter and Convex mutation logic are otherwise well-implemented with thorough tests.

src/services/notifications-settings.ts — the async picker-mount block needs a post-await abort check. convex/http.ts — minor validation gap in the set-notification-config path.

Important Files Changed

Filename Overview
convex/alertRules.ts Adds countries arg to three mutations via a shared normalizeCountries() helper; normalization (trim, uppercase, regex, dedupe, cap-50) and preserve-on-omit semantics are correctly implemented.
scripts/notification-relay.cjs Adds eventMatchesCountryScope helper with correct strict-no-attribution semantics and multi-field extraction priority; properly wired into the per-rule realtime matching filter.
src/services/notifications-settings.ts Mounts the country chip picker on reload; missing signal.aborted guard after await loadFollowedCountriesSafe() can leave a stale countryPicker handle attached to orphaned DOM, risking a ghost mutation on the debounced save path.
src/utils/country-chip-picker.ts New self-contained chip picker with correct normalizeIso2 mirroring the server regex, safe dynamic-import degradation via /* @vite-ignore */, and proper destroy/cleanup logic.
convex/http.ts Passes countries through both HTTP action handlers; set-alert-rules correctly validates with a 400, but set-notification-config silently drops a non-array value instead of rejecting it.
convex/schema.ts Adds countries: v.optional(v.array(v.string())) to alertRules; backward-compatible since absent field means all-countries.
api/notification-channels.ts Threads countries through the Vercel Edge handler for both set-alert-rules and set-notification-config actions with no functional issues.
src/services/notification-channels.ts Adds countries?: string[] to AlertRule interface and setNotificationConfig args; straightforward type extension with no issues.

Sequence Diagram

sequenceDiagram
    participant UI as Country Chip Picker
    participant NS as notifications-settings.ts
    participant API as api/notification-channels.ts
    participant HTTP as convex/http.ts
    participant MUT as alertRules mutation
    participant DB as Convex DB (alertRules)
    participant RELAY as notification-relay.cjs

    UI->>NS: onChange (debounced 800ms)
    NS->>NS: saveCurrentAlertRule() reads DOM + picker.getValue()
    NS->>API: "POST /api/notification-channels {action, countries:[...]}"
    API->>HTTP: "convexRelay({countries})"
    HTTP->>HTTP: validate Array.isArray(countries)
    HTTP->>MUT: "setAlertRulesForUser({countries})"
    MUT->>MUT: normalizeCountries() trim/upper/regex/dedupe/cap-50
    MUT->>DB: "patch({countries:[...]})"

    Note over RELAY: On incoming event
    RELAY->>RELAY: eventMatchesCountryScope(event, rule)
    RELAY->>RELAY: extract countryCode to country to event.country
    RELAY->>RELAY: normalize + regex check
    RELAY->>RELAY: rule.countries.includes(normalized)
    RELAY-->>RELAY: deliver or drop
Loading

Comments Outside Diff (1)

  1. convex/http.ts, line 597-600 (link)

    P2 Inconsistent countries validation between the two action handlers

    set-alert-rules explicitly rejects a non-array countries with a 400, but set-notification-config silently coerces a non-array value to undefined. A caller that passes countries: "US" to set-notification-config will receive a 200 even though the field was silently dropped. Adding the same guard keeps the two paths consistent.

Reviews (1): Last reviewed commit: "feat(alertRules): country-scoping for al..." | Re-trigger Greptile

Comment on lines +431 to +445
if (isNewRule) {
const followed = await loadFollowedCountriesSafe();
if (followed.length > 0) initial = followed;
}

countryPicker = mountCountryChipPicker(pickerRoot, {
initial,
onChange: () => {
// Debounced save through the existing alertRule pipeline.
if (alertRuleDebounceTimer) clearTimeout(alertRuleDebounceTimer);
alertRuleDebounceTimer = setTimeout(() => {
saveCurrentAlertRule();
}, 800);
},
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing abort-signal check after async gap

loadFollowedCountriesSafe() may call into the getFollowed service, which can take hundreds of milliseconds. If the user closes the settings panel during that window, signal is aborted and the component tears down — but there is no if (signal.aborted) return; guard between the await and the mountCountryChipPicker call. The result is: countryPicker is assigned a live handle pointing at an orphaned DOM element, the onChange debounce arm still fires 800 ms later, and saveCurrentAlertRule calls countryPicker.getValue() on that orphaned picker — sending a ghost mutation to Convex for a rule the user may have already edited or deleted.

…-path centralization, window-registry smart-default

PR #3632 review surfaced three independent issues. Each fix addresses a distinct failure mode:

# Finding 1 (P1) — strict country-scope filter dropped EVERY alert for users with countries=['US']

**Root cause:** scripts/notification-relay.cjs::eventMatchesCountryScope strict-rejected events without country attribution. Most current publishers (rss_alert, ais-relay generic events) don't emit payload.countryCode/payload.country/event.country, so a user setting countries=['US'] received ZERO alerts (silent under-delivery, not "US-only alerts").

**Fix:** flipped to PERMISSIVE-on-unattributed semantics. When the publisher gives no country info (or a malformed value like 'USA' / 'United States'), deliver the event. Strict semantics still apply when the event IS attributed but doesn't match (rule.countries=['US'] + event.country='IR' → drop). Inline RATIONALE block documents the trade-off and the future strict-mode opt-in path. Test file updated to assert permissive behavior on unattributed/empty/whitespace/malformed inputs and strict behavior on attributed-mismatch.

# Finding 2 (P1) — preselectCountry / picker value lost on non-picker save paths

**Root cause:** four different alertRules save handlers in src/services/notifications-settings.ts (saveCurrentAlertRule, saveRuleWithNewChannel, AI digest toggle, enable+sensitivity toggle) each hand-rolled their own AlertRule payload. Three of them omitted `countries`. When the user opened settings via deep-dive R9 with picker pre-checking 'IR' and immediately connected Telegram, the channel-connect save raced the picker's debounced save and wrote alertRule WITHOUT countries — reload then saw the new row, treated it as "no smart-default" (existingRule != null), and IR was lost.

**Fix:** centralized via getCurrentAlertRuleFormState() helper that returns the full {enabled, eventTypes, sensitivity, channels, aiDigestEnabled, countries} snapshot from the live form + picker. Every saveAlertRules call site now spreads `...state`. Source-grep test enforces the invariant going forward (catches any future hand-rolled payload regression).

# Finding 3 (P2) — loadFollowedCountriesSafe dynamic-import + @vite-ignore + alias broken in browser builds

**Root cause:** src/utils/country-chip-picker.ts:198 used `import(/* @vite-ignore */ path)` with `path = '@/services/followed-countries'`. The @vite-ignore directive prevents Vite from resolving the alias at build time — the unresolvable raw alias survives into the browser bundle, the browser rejects the module URL, and the smart-default always falls back to []. The graceful-degradation path was the ONLY path executed in production.

**Fix:** replaced with a window-registry pattern. PR A's followed-countries module (when it ships) self-registers on `window.__wmFollowedCountries`. The picker reads from `window.__wmFollowedCountries?.getFollowed()` at call time — no static or dynamic import, no Vite alias coupling. PR A and PR #3632 stay shipping-independent. The function is now synchronous (no Promise return); the caller in notifications-settings.ts no longer awaits. Test file mocks the window registry instead of the dynamic import; absent registry → returns [] (degraded behavior unchanged).

# Verification

- `npm run typecheck` clean
- `npm run typecheck:api` clean
- `npm run test:convex` — 225 pass (no convex changes)
- `npm run test:data` — 8312 pass (8293 baseline → +19 for new permissive-filter + window-registry + save-path-centralization assertions)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant