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
This commit is contained in:
Elie Habib
2026-04-02 22:17:24 +04:00
committed by GitHub
parent 718f466689
commit c51717e76a
12 changed files with 1028 additions and 6 deletions

View File

@@ -27,8 +27,10 @@ export const STORY_TRACK_KEY = (titleHash: string) => `story:track:v1:${titleHas
export const STORY_SOURCES_KEY = (titleHash: string) => `story:sources:v1:${titleHash}`;
export const STORY_PEAK_KEY = (titleHash: string) => `story:peak:v1:${titleHash}`;
export const DIGEST_ACCUMULATOR_KEY = (variant: string) => `digest:accumulator:v1:${variant}`;
export const DIGEST_LAST_SENT_KEY = (userId: string, variant: string) => `digest:last-sent:v1:${userId}:${variant}`;
export const SHADOW_SCORE_LOG_KEY = 'shadow:score-log:v1';
export const STORY_TTL = 604800; // 7 days — enough for sustained multi-day stories without resetting phase history
export const STORY_TTL = 604800; // 7 days — enough for sustained multi-day stories
export const DIGEST_ACCUMULATOR_TTL = 172800; // 48h — lookback window for digest content
/**
* Shared Redis pointer keys for simulation artifacts.