Commit Graph

12 Commits

Author SHA1 Message Date
Elie Habib
3d7e60ca7d fix(digest): never skip AI summary when userPreferences are missing (#2939)
* fix(digest): never skip AI summary when userPreferences are missing

Users who enabled the AI executive summary toggle on their notification
rule still received digest emails without the summary. The Railway log
pinpointed it:

  [digest] No preferences for user_... skipping AI summary
  [digest] Email delivered to ...

Root cause chain:
  convex/http.ts:591            /relay/user-preferences returns literal
                                null when no userPreferences row exists
                                for (userId, variant).
  scripts/lib/user-context.cjs  fetchUserPreferences forwards that as
                                { data: null, error: false }.
  scripts/seed-digest-notifications.mjs:458
                                generateAISummary bails with return null.

The AI-summary toggle lives on the alertRules table. userPreferences is
a SEPARATE table (the SPA app-settings blob: watchlist, airports,
panels). A user can have an alertRule (with aiDigestEnabled: true)
without having ever saved userPreferences, or only under a different
variant. Missing prefs must NOT silently disable the feature the user
explicitly enabled. The correct behavior is to degrade to a
non-personalized summary.

Fix: remove the early return in generateAISummary. Call
extractUserContext(null), which already returns a safe empty context,
and formatUserProfile(ctx, 'full') returns "Variant: full" alone. The
LLM then generates a generic daily brief instead of nothing. An info
log still reports the missing-prefs case for observability.

Regression coverage: tests/user-context.test.mjs (new, 10 cases) locks
in that extractUserContext(null|undefined|{}|"") returns the empty
shape and formatUserProfile(emptyCtx, variant) returns exactly
"Variant: {variant}". Any future refactor that reintroduces the
null-bail will fail the tests.

Note: the same log also shows the rule fired at 13:01 Dubai instead
of 8 AM / 8 PM. That is a separate issue in isDue or rule-save flow
and needs more log data to diagnose; not included here.

* fix(digest): distinguish transient prefs fetch failure from missing row

Addresses Greptile P2 review feedback on PR #2939.

fetchUserPreferences returns { data, error } where:
  error: true  = transient fetch failure (network, non-OK HTTP, env missing)
  error: false = the (userId, variant) row genuinely does not exist

The previous log treated both cases identically as "No stored preferences",
which was misleading when the real cause was an unreachable Convex endpoint.
Behavior is unchanged (both still degrade to a non-personalized summary),
only the log line differentiates them so transient fetch failures are
visible in observability.
2026-04-11 17:10:06 +04:00
Elie Habib
c8084e3c29 fix(digest): render AI summary markdown across all channels (#2935) 2026-04-11 09:39:27 +04:00
Elie Habib
b99ceacc37 fix(emails): redesign intelligence brief email template (#2933)
- Width: 600px -> 680px (less constrained)
- Outer background: #111 (not full black #0a0a0a)
- Header: logo + date on same row, cleaner layout
- AI Summary: injected via slot below header, above stats
  (was above the header via regex, wrong order)
- Stats cards: separated with gaps, individual borders
- Footer: cleaned up, added Discord, fixed X handle
- Summary slot system replaces fragile regex injection
2026-04-11 08:35:11 +04:00
Elie Habib
e14dc5b103 feat(notifications): PRO entitlement check before delivery (#2899)
* feat(notifications): PRO entitlement check before delivery in relay/digest/proactive

All three notification delivery paths now verify the user has PRO tier
before sending. Uses a new /relay/entitlement Convex HTTP endpoint
(RELAY_SHARED_SECRET auth) with 15min Redis cache per user.

Fail-open design: if entitlement service is unreachable, delivery
proceeds (don't block paying users on a service hiccup). Cache shared
across relay, digest, and proactive scripts via relay:entitlement:{userId}.

Prevents downgraded users from continuing to receive notifications
after their subscription expires.

Requires Convex deploy for the new /relay/entitlement route.

* fix(notifications): show second delivery time for twice_daily digest mode

When user selects "Twice daily", show "Also sends at X PM/AM" hint below
the hour selector so they know when the second digest arrives.
Updates dynamically when hour or mode changes.
2026-04-10 15:34:52 +04:00
Elie Habib
d85bee163e fix(seeds): remove military-bases from static-ref bundle (#2893)
* fix(seeds): remove military-bases from static-ref bundle

seed-military-bases.mjs is a one-time batch upload tool requiring
--env and --sha CLI args. Without args it exits code 1. It writes
no seed-meta so the freshness gate always tries to run it, always
fails, causing the bundle to exit non-zero.

* fix(notifications): wrap Telegram/Slack/Discord sends in try/catch

Unhandled fetch timeout (ETIMEDOUT) in sendTelegram crashed the entire
digest cron run. Email delivery succeeded but subsequent Telegram
delivery threw an uncaught error, killing the process.

Wrapped all three webhook-style send functions (Telegram, Slack, Discord)
in try/catch so network timeouts log a warning and return false instead
of crashing. sendEmail already had this pattern.
2026-04-10 14:10:30 +04:00
Elie Habib
00320c26cf feat(notifications): proactive intelligence agent (Phase 4) (#2889)
* feat(notifications): proactive intelligence agent (Phase 4)

New Railway cron (every 6 hours) that detects signal landscape changes
and generates proactive intelligence briefs before events break.

Reads ~8 Redis signal keys (CII risk, GPS interference, unrest, sanctions,
cyber threats, thermal anomalies, weather, commodities), computes a
landscape snapshot, diffs against the previous run, and generates an
LLM brief when the diff score exceeds threshold.

Key features:
- Signal landscape diff with weighted scoring (new risk countries = 2pts,
  GPS zone changes = 1pt per zone, commodity movers >3% = 1pt)
- Server-side convergence detection: countries with 3+ signal types flagged
- First run stores baseline only (no false-positive brief)
- Delivers via all 5 channels (Telegram, Slack, Discord, Email, Webhook)
- PROACTIVE_INTEL_ENABLED=0 env var to disable
- Skips users without saved preferences or deliverable channels

Requires: Railway cron service configuration (every 6 hours)

* fix(proactive): fetch all enabled rules + expand convergence to all signal types

1. Replace /relay/digest-rules (digest-mode only) with ConvexHttpClient
   query alertRules:getByEnabled to include ALL enabled rules, not just
   digest-mode users. Proactive briefs now reach real-time users too.
2. Expand convergence detection from 3 signal families (risk, unrest,
   sanctions) to all 7 (add GPS interference, cyber, thermal, weather).
   Track signal TYPES per country (Set), not event counts, so convergence
   means 3+ distinct signal categories, not 3+ events from one category.
3. Include signal type names in convergence zone output for LLM context
   and webhook payload.

* fix(proactive): check channels before LLM + deactivate stale channels

1. Move channel fetch + deliverability check BEFORE user prefs and LLM
   call to avoid wasting LLM calls on users with no verified channels
2. Add deactivateChannel() calls on 404/410/403 responses in all delivery
   helpers (Telegram, Slack, Discord, Webhook), matching the pattern in
   notification-relay.cjs and seed-digest-notifications.mjs

* fix(proactive): preserve landscape on transient failures + drop Telegram Markdown

1. Don't advance landscape baseline when channel fetch or LLM fails,
   so the brief retries on the next run instead of permanently suppressing
   the change window
2. Remove parse_mode: 'Markdown' from Telegram sendMessage to avoid 400
   errors from unescaped characters in LLM output (matches digest pattern)

* fix(proactive): only advance landscape baseline after successful delivery

* fix(proactive): abort on degraded signals + don't advance on prefs failure

1. Track loaded signal key count. Abort run if <60% of keys loaded
   to prevent false diffs from degraded Redis snapshots becoming
   the new baseline.
2. Don't advance landscape when fetchUserPreferences() returns null
   (could be transient failure, not just "no saved prefs"). Retries
   next run instead of permanently suppressing the brief.

* fix(notifications): distinguish no-prefs from fetch-error in user-context

fetchUserPreferences() now returns { data, error } instead of bare null.
error=true means transient failure (retry next run, don't advance baseline).
data=null + error=false means user has no saved preferences (skip + advance).

Proactive script: retry on error, skip+advance on no-prefs.
Digest script: updated to destructure new return shape (behavior unchanged,
  both cases skip AI summary).

* fix(proactive): address all Greptile review comments

P1: Add link-local (169.254) and 0.0.0.0 to isPrivateIP SSRF check
P1: Log channel-fetch failures (was silent catch{})
P2: Remove unused createHash import and BRIEF_TTL constant
P2: Remove dead ?? 'full' fallback (rule.variant validated above)
P2: Add HTTPS enforcement to sendSlack/sendDiscord (matching sendWebhook)
2026-04-10 08:08:27 +04:00
Elie Habib
12203a4f51 feat(notifications): generic webhook channel (Phase 3) (#2887)
* feat(notifications): generic webhook channel (Phase 3)

Add webhook as a 5th notification channel type. Users provide an HTTPS
URL, WorldMonitor POSTs structured JSON payloads to it. Enables
integration with Zapier, n8n, IFTTT, and custom pipelines.

Schema: webhook variant in notificationChannels with webhookEnvelope
(AES-256-GCM encrypted URL), webhookLabel, webhookSecret fields.

Relay: sendWebhook() with SSRF protection (DNS resolve + private IP
check), HTTPS-only enforcement, auto-deactivation on 404/410/403.

Digest cron: sendWebhook() delivers digest as structured JSON with
stories array, AI summary, and story count.

Requires Convex deploy for schema changes.

* fix(notifications): webhook UI, label persistence, SSRF fail-closed

Address review findings on PR #2887:

1. Add webhook to settings UI: channel row with URL input, label field,
   connect/cancel/save buttons, icon, and connected state display
2. Forward webhookLabel through edge function -> Convex relay -> mutation,
   persist in notificationChannels table (was silently discarded)
3. Fix digest sendWebhook SSRF: dns.resolve4().catch(()=>[]) fails open
   on IPv6-only hosts; now fails closed like the relay version

* fix(notifications): validate webhook URL at connect time + add webhookLabel to public mutation

1. Edge function now validates webhook URLs before encrypting: HTTPS required,
   private/local hostnames rejected (localhost, 127.*, 10.*, 192.168.*, etc.)
   Invalid URLs caught at connect time rather than silently failing on delivery.
2. Public setChannel mutation now accepts and persists webhookLabel,
   matching the internal mutation and schema.

* fix(notifications): include held alert details in webhook quiet-hours batch

Webhook batch_on_wake delivery now sends full alert details (eventType,
severity, title per alert) instead of just the batch subject line,
matching the information density of Slack/Discord/Email delivery.
2026-04-09 23:22:44 +04:00
Elie Habib
fa64e2f61f feat(notifications): AI-enriched digest delivery (#2876)
* feat(notifications): AI-enriched digest delivery (Phase 1)

Add personalized LLM-generated executive summaries to digest
notifications. When AI_DIGEST_ENABLED=1 (default), the digest cron
fetches user preferences (watchlist, panels, frameworks), generates a
tailored intelligence brief via Groq/OpenRouter, and prepends it to the
story list in both text and HTML formats.

New infrastructure:
- convex/userPreferences: internalQuery for service-to-service access
- convex/http: /relay/user-preferences endpoint (RELAY_SHARED_SECRET auth)
- scripts/lib/llm-chain.cjs: shared Ollama->Groq->OpenRouter provider chain
- scripts/lib/user-context.cjs: user preference extraction + LLM prompt formatting

AI summary is cached (1h TTL) per stories+userContext hash. Falls back
to raw digest on LLM failure (no regression). Subject line changes to
"Intelligence Brief" when AI summary is present.

* feat(notifications): per-user AI digest opt-out toggle

AI executive summary in digests is now optional per user via
alertRules.aiDigestEnabled (default true). Users can toggle it off in
Settings > Notifications > Digest > "AI executive summary".

Schema: added aiDigestEnabled to alertRules table
Backend: Convex mutations, HTTP relay, edge function all forward the field
Frontend: toggle in digest settings section with descriptive copy
Digest cron: skips LLM call when rule.aiDigestEnabled === false

* fix(notifications): address PR review — cache key, HTML replacement, UA

1. Add variant to AI summary cache key to prevent cross-variant poisoning
2. Use replacer function in html.replace() to avoid $-pattern corruption
   from LLM output containing dollar amounts ($500M, $1T)
3. Use service UA (worldmonitor-llm/1.0) instead of Chrome UA for LLM calls

* fix(notifications): skip AI summary without prefs + fix HTML regex

1. Return null from generateAISummary() when fetchUserPreferences()
   returns null, so users without saved preferences get raw digest
   instead of a generic LLM summary
2. Fix HTML replace regex to match actual padding value (40px 32px 0)
   so the executive summary block is inserted in email HTML

* fix(notifications): channel check before LLM, omission-safe aiDigest, richer cache key

1. Move channel fetch + deliverability check BEFORE AI summary generation
   so users with no verified channels don't burn LLM calls every cron run
2. Only patch aiDigestEnabled when explicitly provided (not undefined),
   preventing stale frontend tabs from silently clearing an opt-out
3. Include severity, phase, and sources in story hash for cache key
   so the summary invalidates when those fields change
2026-04-09 21:35:26 +04:00
Elie Habib
65b4655dc6 fix(digest): namespace accumulator by language, add per-severity caps (#2826)
* fix(digest): namespace accumulator by language, add per-severity caps

Root cause: digest:accumulator:v1:${variant} was shared across all
languages. A buildDigest("full", "de") request wrote German stories
to the same accumulator the English digest cron consumed, leaking
non-English headlines into English email digests.

Fix (3 layers):
1. Accumulator key is now language-aware:
   digest:accumulator:v1:${variant}:${lang}
   writeStoryTracking receives lang and writes to the correct key.
   Cron reads from the lang-specific key (defaults to 'en').

2. Defense-in-depth: lang is stored on story:track:v1:* hash records.
   Cron filters stories where track.lang !== target lang.

3. Per-severity display caps use named constants:
   CRITICAL=Infinity, HIGH=15, MEDIUM=10 (was hardcoded 10 for all).
   Both text and HTML formatters use the same constants.

* fix(digest): remove track.lang cron filter, rely solely on accumulator key

track.lang is written by writeStoryTracking via HSET which overwrites
on every call. If the same normalized title appears in multiple
languages within the 48h TTL, the last writer wins the lang field.
Using it as a cron-side filter creates a race where valid stories get
dropped. The accumulator key namespacing (variant:lang) is the sole
language isolation mechanism.
2026-04-08 17:28:21 +04:00
Elie Habib
998c554a6f feat(payments): subscription welcome email + admin notification (#2809)
* feat(payments): subscription welcome email + admin notification

On subscription.active webhook:
1. Send branded welcome email to user via Resend (matches WM design)
2. Send admin notification to elie@worldmonitor.app with plan, email, userId

Also removed the Dodo customer block from checkout creation since
Dodo locks prefilled customer fields as read-only, preventing users
from editing their email/name during payment.

* fix(payments): correct email feature cards per plan tier + fix plan name mapping

Pro plans showed "Full API Access" which is false (apiAccess: false in catalog).
Now shows plan-appropriate features: Pro gets dashboards/alerts, API plans get
API access. Also aligned PLAN_DISPLAY keys with actual catalog planKeys
(api_starter, api_starter_annual, api_business, enterprise).

* fix(payments): address Greptile review on subscription emails

P1: Throw on Resend failure so Convex retries transient errors (5xx,
429, network) instead of silently dropping emails.

P2: Only send welcome email for brand-new subscriptions, not
reactivations. Uses the existing `existing` variable to distinguish.

P2: Log a warning when customer email is missing from the webhook
payload so dropped emails are visible in logs.

* fix(emails): replace placeholder logo and remove Someone.ceo branding

All 3 email templates (subscription welcome, register-interest, daily
digest) used a Unicode circle character as a placeholder logo and
"by Someone.ceo" as a subtitle. Replaced with the actual hosted
WorldMonitor favicon and removed the Someone.ceo line.
2026-04-08 08:05:32 +04:00
Elie Habib
2e3de91192 fix(digest): deduplicate near-identical stories in notifications (#2724)
* fix(digest): deduplicate near-identical stories in notification digests

RSS feeds from Reuters, AP, BBC, etc. publish the same event with
slightly different headlines ("search underway" vs "search under way",
"- reuters.com" vs "- Reuters"). Each variant got a unique title hash,
flooding digests with 5-10 copies of the same story.

Two-layer fix:
- Notification script: Jaccard word-overlap clustering (>55% threshold)
  merges near-duplicates, keeps highest-scored representative, sums
  mention counts and merges sources.
- Server normalizeTitle: strips source attribution suffixes before
  hashing so "Title - reuters.com" and "Title - Reuters" share one
  accumulator entry going forward.
- Display: strip source suffixes from formatted titles (source already
  shown separately as [Reuters US, ...]).

* fix(digest): fetch sources for all merged hashes, not just representative

deduplicateStories() runs before SMEMBERS fetch, so merged stories'
source sets were lost. Now carries mergedHashes array through clustering
and fetches story:sources for every hash in the cluster, unioning them.

* style: drop redundant /i flag from normalizeTitle regexes

Input is already lowercased by .toLowerCase() before the regex runs.
2026-04-05 13:47:53 +04:00
Elie Habib
c51717e76a feat(digest): daily digest notification mode (#2614)
* feat(digest): add daily digest notification mode (Enhancement 2)

- convex/schema.ts: add digestMode/digestHour/digestTimezone to alertRules
- convex/alertRules.ts: setDigestSettings mutation, setDigestSettingsForUser
  internal mutation, getDigestRules internal query
- convex/http.ts: GET /relay/digest-rules for Railway cron; set-digest-settings
  action in /relay/notification-channels
- cache-keys.ts: DIGEST_LAST_SENT_KEY + DIGEST_ACCUMULATOR_TTL (48h); fix
  accumulator EXPIRE to use 48h instead of 7-day STORY_TTL
- notification-relay.cjs: skip digest-mode rules in processEvent — prevents
  daily/weekly users from receiving both real-time and digest messages
- seed-digest-notifications.mjs: new Railway cron (every 30 min) — queries
  due rules, ZRANGEBYSCORE accumulator, batch HGETALL story tracks, derives
  phase, formats digest per channel, updates digest:last-sent
- notification-channels.ts: DigestMode type, digest fields on AlertRule,
  setDigestSettings() client function
- api/notification-channels.ts: set-digest-settings action

* fix(digest): correct twice_daily scheduling and only advance lastSent on confirmed delivery

isDue() only checked a single hour slot, so twice_daily users got one digest per day
instead of two. Now checks both primaryHour and (primaryHour+12)%24 for twice_daily.

All four send functions returned void and errors were swallowed, causing dispatched=true
to be set unconditionally. Replaced with boolean returns and anyDelivered guard so
lastSentKey is only written when at least one channel confirms a 2xx delivery.

* fix(digest): add discord to deactivate allowlist, bounds-check digestHour, minor cleanup

/relay/deactivate was rejecting channelType="discord" with 400, so stale Discord
webhooks were never auto-deactivated. Added "discord" to the validation guard.

Added 0-23 integer bounds check for digestHour in both setDigestSettings mutations
to reject bad values at the DB layer rather than silently storing them.

Removed unused createHash import and added AbortSignal.timeout(10000) to
upstashRest to match upstashPipeline and prevent cron hangs.

* fix(daily-digest): add DIGEST_CRON_ENABLED guard, IANA timezone validation, and Digest Mode UI

- seed-digest-notifications.mjs: exit 0 when DIGEST_CRON_ENABLED=0 so Railway
  cron does not error on intentionally disabled runs
- convex/alertRules.ts: validate digestTimezone via Intl.DateTimeFormat; throw
  ConvexError with descriptive message for invalid IANA strings
- preferences-content.ts: add Digest Mode section with mode select (realtime/
  daily/twice_daily/weekly), delivery hour select, and timezone input; details
  panel hidden in realtime mode; wired to setDigestSettings with 800ms debounce

Fixes gaps F, G, I from docs/plans/2026-04-02-003-fix-news-alerts-pr-gaps-plan.md

* fix(digest): close digest blackhole and wire timezone validation through internal mutation

- convex/alertRules.ts: add IANA timezone validation to setDigestSettingsForUser
  (internalMutation called by http.ts); the public mutation already validated but
  the edge/relay path bypassed it
- preferences-content.ts: add VITE_DIGEST_CRON_ENABLED browser flag; when =0,
  disable the digest mode select and show only Real-time with a note so users
  cannot enter a blackhole state where the relay skips their rule and the cron
  never runs

Addresses P1 and P2 review findings on #2614

* fix(digest): restore missing > closing the usDigestDetails div opening tag

* feat(digest): redesign email to match WorldMonitor design system

Dark theme (#0a0a0a bg, #111 cards), #4ade80 green accent, 4px top bar,
table-based logo header, severity-bucketed story cards with colored left
borders, stats row (total/critical/high), green CTA button. Plain text
fallback preserved for Telegram/Slack/Discord channels.

* test(digest): add rollout-flag and timezone-validation regression tests

Covers three paths flagged as untested by reviewers:
- VITE_DIGEST_CRON_ENABLED gates digest-mode options and usDigestDetails visibility
- setDigestSettings (public) validates digestTimezone via Intl.DateTimeFormat
- setDigestSettingsForUser (internalMutation) also validates digestTimezone
  to prevent silent bypass through the edge-to-Convex path
2026-04-02 22:17:24 +04:00