feat(alertRules): country-scoping — schema + mutation + relay filter + UI picker#3632
feat(alertRules): country-scoping — schema + mutation + relay filter + UI picker#3632koala73 wants to merge 3 commits into
Conversation
… + 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.'
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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 SummaryThis PR adds optional country-scoping to
Confidence Score: 3/5Safe 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
Sequence DiagramsequenceDiagram
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
|
| 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); | ||
| }, | ||
| }); |
There was a problem hiding this comment.
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)
Summary
Adds an optional
countriesfield toalertRulesso 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'sgetFollowed()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:
Convex schema (
convex/schema.ts) —countries: v.optional(v.array(v.string()))onalertRules. No migration; existing rows treated as "all countries".Convex mutations (
convex/alertRules.ts) —setAlertRules,setAlertRulesForUser,setNotificationConfigForUseracceptcountriesarg via sharednormalizeCountries(). 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'sconvex/lib/iso2.ts.HTTP / API forwarding (
convex/http.ts,api/notification-channels.ts,src/services/notification-channels.ts) —countriesflows through the full HTTP → API → mutation chain.Relay filter (
scripts/notification-relay.cjs) — neweventMatchesCountryScope(event, rule)helper in the per-rule eligibility filter. Country attribution priority:event.payload.countryCode→event.payload.country→event.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.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 vialoadFollowedCountriesSafe()— synchronous read fromwindow.__wmFollowedCountries?.getFollowed(). PR A's followed-countries module (when it ships) self-registers onwindow.__wmFollowedCountries; this picker reads if-present, returns[]if absent. Smart-default ONLY applies on NEW-rule create; editing existing rules respects storedcountries. Centralized save state viagetCurrentAlertRuleFormState()so every alertRules save path threadscountriesthrough.Test plan
npm run typecheck— cleannpm run typecheck:api— cleannpm run test:convex— 225 passnpm run test:data— 8312 pass (+ ~52 new across the three layers)vite build— succeeds (verified the window-registry approach has no Vite alias coupling)New tests (highlights):
loadFollowedCountriesSafewindow-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
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).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.loadFollowedCountriesSafe()reads fromwindow.__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-ignoretricks, no dynamic import that breaks in production browser builds.'XX'passes the regex but no event will match it; keeps this PR independent of PR A's canonical alpha-2 list.setAlertRules—setAlertRulesForUser(HTTP path) andsetNotificationConfigForUser(atomic delivery-mode flow) also accept the field; without those, thecountriesarg would only persist via the direct-mutation path.Unlocks
src/utils/notify-country-link.ts— wire-swap once both PRs land.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
countFollowersrate limit (still open from PR A). Vercel edge wrapper.rule.countriesStrict=true) can opt users into strict drop-on-missing once publisher attribution coverage is high enough.convex-schema-deployment-mismatch). Convex auto-deploys on merge; the schema field is optional so handlers deploy in the same push without race risk.