Skip to content

feat(digest): daily digest notification mode#2614

Merged
koala73 merged 8 commits intomainfrom
feat/news-alerts-daily-digest
Apr 2, 2026
Merged

feat(digest): daily digest notification mode#2614
koala73 merged 8 commits intomainfrom
feat/news-alerts-daily-digest

Conversation

@koala73
Copy link
Copy Markdown
Owner

@koala73 koala73 commented Apr 2, 2026

Summary

  • Adds daily/twice-daily/weekly digest mode to alert rules — users can now receive a single scheduled briefing instead of real-time alerts
  • notification-relay.cjs skips digest-mode rules so users don't get double-notified
  • New Railway cron seed-digest-notifications.mjs runs every 30 min, compiles digests from the Redis accumulator (E3), and dispatches to all configured channels
  • Full stack: Convex schema + mutations + HTTP action → Vercel edge → client service

Changes by layer

Convex (schema/mutations/HTTP)

  • schema.ts: digestMode / digestHour / digestTimezone optional fields on alertRules (absent = realtime)
  • constants.ts: digestModeValidator union (realtime | daily | twice_daily | weekly)
  • alertRules.ts: setDigestSettings mutation, setDigestSettingsForUser internal mutation, getDigestRules internal query
  • http.ts: GET /relay/digest-rules (Railway auth via RELAY_SHARED_SECRET); set-digest-settings action in /relay/notification-channels

Shared/server

  • cache-keys.ts: DIGEST_LAST_SENT_KEY + DIGEST_ACCUMULATOR_TTL (48h); fixes accumulator EXPIRE to use 48h instead of 7-day STORY_TTL

Relay

  • notification-relay.cjs: adds !r.digestMode || r.digestMode === 'realtime' guard to processEvent filter

New Railway cron

  • seed-digest-notifications.mjs: fetches due rules → ZRANGEBYSCORE accumulator → batch HGETALL story tracks → derive phase → format per channel → dispatch → update digest:last-sent:v1:${userId}:${variant} (8-day TTL)
  • Schedule dedup via isDue() with per-mode minimum intervals (23h daily, 11h twice-daily, 6.5d weekly) to prevent double-sends on 30-min cron cadence
  • First-send uses 24h lookback window; subsequent sends use lastSentAt from Redis

Client

  • notification-channels.ts: DigestMode type, digest fields on AlertRule, setDigestSettings() function
  • api/notification-channels.ts: set-digest-settings action in Vercel edge

Dependencies

Stacks on top of PR #2604 (E3 story tracking) — the digest:accumulator:v1:${variant} sorted set written there is the data source for digest content.

Test plan

  • npm run typecheck + npm run typecheck:api — PASS
  • npm run test:data (2729 tests) — PASS
  • node --test tests/edge-functions.test.mjs (146 tests) — PASS
  • npm run lint:md — 0 errors
  • npm run version:check — OK
  • After merge of feat(scoring): composite importance score + story tracking infrastructure #2604: verify digest:accumulator has entries in Redis
  • Set digestMode: daily on a test rule, run seed-digest-notifications.mjs manually, confirm delivery and digest:last-sent key written

Post-Deploy Monitoring & Validation

  • Logs: [digest] Cron run start/complete lines in Railway cron service logs every 30 min
  • Healthy signal: [digest] Sent N stories to <userId> on scheduled runs; [digest] No digest rules found when no users have opted in yet
  • Failure signals: [digest] Fatal: exits with code 1 (Railway restarts); repeated Upstash error means Redis connectivity issue
  • Validation: After adding a test rule with digestMode: daily, check Redis ZCARD digest:accumulator:v1:full > 0, then run cron manually and verify digest:last-sent:v1:<userId>:full is set
  • Rollback: Remove/disable Railway cron service; existing realtime rules unaffected (relay filter is additive, absent = realtime)

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 2, 2026

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

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 2, 2026 6:18pm

Request Review

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 2, 2026

Greptile Summary

This PR introduces a daily/twice-daily/weekly digest notification mode as an alternative to real-time alerts. The implementation is full-stack: Convex schema and mutations for storing digest preferences, a new GET /relay/digest-rules HTTP endpoint for the Railway cron, a guard in notification-relay.cjs to skip digest-mode rules, a new seed-digest-notifications.mjs Railway cron that compiles stories from the Redis accumulator and dispatches per-channel, and client-side type/service additions.

The overall architecture is solid and follows established patterns in the codebase (relay shared-secret auth, timing-safe comparisons, per-channel SSRF guards). However, two functional bugs were found in the new cron script:

  • twice_daily sends only once per day: isDue() checks localHour === targetHour using the single digestHour field. Since there is no second configurable hour and the cron only fires when the local hour matches, the 11-hour minimum interval never allows a second daily send at a different time — twice_daily is functionally identical to daily.
  • Discord webhook auto-deactivation silently fails: deactivateChannel(userId, 'discord') is called when a Discord webhook returns 404/410, but the /relay/deactivate Convex endpoint validates channelType against ["telegram", "slack", "email"] only — "discord" is rejected with a 400, so stale Discord webhooks are never removed.

Additional minor findings:

  • import { createHash } from 'node:crypto' is unused in seed-digest-notifications.mjs.
  • upstashRest lacks an AbortSignal.timeout(), unlike upstashPipeline.
  • digestHour accepts any number with no 0–23 bounds check at the Convex mutation layer.

Confidence Score: 3/5

  • Safe to merge for the daily/weekly modes, but twice_daily will not work as advertised and Discord channels will accumulate stale entries.
  • The real-time relay guard, Convex schema changes, and client service additions are all correct. Two bugs in the new cron script affect specific modes/channels (twice_daily logic broken, Discord deactivation silently no-ops), and a missing timeout on upstashRest could cause the cron to hang. These aren't regressions to existing functionality but do mean two of the four digest modes or one of four channel types won't behave as intended post-deploy.
  • scripts/seed-digest-notifications.mjs (twice_daily logic, Discord deactivation, missing timeout, unused import) and convex/http.ts (deactivate allowlist missing "discord")

Important Files Changed

Filename Overview
scripts/seed-digest-notifications.mjs New Railway cron that compiles and dispatches digest notifications; contains two logic bugs: twice_daily effectively sends once/day (single digestHour check), and Discord auto-deactivation silently fails due to the /relay/deactivate endpoint rejecting "discord" as a channel type. Also has an unused createHash import and missing timeout on upstashRest.
convex/http.ts Adds GET /relay/digest-rules endpoint (Railway auth) and set-digest-settings action in /relay/notification-channels; both are correctly guarded with timingSafeEqualStrings. The /relay/deactivate endpoint's channel-type allowlist still omits "discord", which causes Discord deactivation calls from the cron to silently fail.
convex/alertRules.ts Adds setDigestSettings, setDigestSettingsForUser, and getDigestRules; all correctly use by_user_variant index for upserts and by_enabled index to fetch active rules. No issues found.
convex/schema.ts Adds optional digestMode, digestHour, and digestTimezone fields to alertRules table; fields are correctly marked optional to maintain backward compatibility.
convex/constants.ts Adds digestModeValidator union type covering all four valid modes; clean and consistent with existing validator pattern.
server/_shared/cache-keys.ts Adds DIGEST_LAST_SENT_KEY helper and DIGEST_ACCUMULATOR_TTL constant (172800s / 48h); correctly fixes the accumulator TTL from the 7-day STORY_TTL to the shorter 48h window needed by the digest cron.
scripts/notification-relay.cjs Single-line guard added to skip digest-mode rules in real-time relay; correctly treats absent digestMode as realtime (additive, no breakage for existing rules).
api/notification-channels.ts Adds set-digest-settings action handler with input validation; correctly authenticates the user via Clerk and forwards to Convex relay.
src/services/notification-channels.ts Adds DigestMode type, digest fields on AlertRule, and setDigestSettings() client function; all consistent with the rest of the service layer.
server/worldmonitor/news/v1/list-feed-digest.ts Imports and applies DIGEST_ACCUMULATOR_TTL (48h) instead of STORY_TTL (7d) for accumulator key expiry; correct fix matching the cron's 24h lookback window.

Sequence Diagram

sequenceDiagram
    participant Client as Browser / Client
    participant Edge as Vercel Edge (api/notification-channels.ts)
    participant Convex as Convex HTTP (convex/http.ts)
    participant DB as Convex DB (alertRules)
    participant Cron as Railway Cron (seed-digest-notifications.mjs)
    participant Redis as Upstash Redis
    participant Ch as Notification Channel

    Note over Client,DB: Setting digest mode
    Client->>Edge: POST /api/notification-channels {action: set-digest-settings}
    Edge->>Edge: Validate Clerk JWT
    Edge->>Convex: POST /relay/notification-channels {action: set-digest-settings, userId, digestMode, ...}
    Convex->>Convex: Verify RELAY_SHARED_SECRET
    Convex->>DB: setDigestSettingsForUser (upsert alertRules)
    DB-->>Convex: ok
    Convex-->>Edge: {ok: true}
    Edge-->>Client: {ok: true}

    Note over Cron,Ch: Every 30 min — digest cron run
    Cron->>Convex: GET /relay/digest-rules (Bearer RELAY_SECRET)
    Convex->>DB: getDigestRules (by_enabled index + in-memory filter)
    DB-->>Convex: rules[]
    Convex-->>Cron: rules[]

    loop For each rule
        Cron->>Redis: GET digest:last-sent:v1:{userId}:{variant}
        Redis-->>Cron: lastSentAt
        Cron->>Cron: isDue(rule, lastSentAt)?
        Cron->>Redis: ZRANGEBYSCORE digest:accumulator:v1:{variant} windowStart now
        Redis-->>Cron: hashes[]
        Cron->>Redis: pipeline HGETALL story:track:v1:{hash} × N
        Redis-->>Cron: track data
        Cron->>Redis: pipeline SMEMBERS story:sources:v1:{hash} × top30
        Redis-->>Cron: sources
        Cron->>Convex: POST /relay/channels {userId}
        Convex-->>Cron: channels[]
        Cron->>Ch: send (Telegram / Slack / Discord / Email)
        Ch-->>Cron: ok / 403 / 404
        alt Channel gone (403/404/410)
            Cron->>Convex: POST /relay/deactivate {userId, channelType}
        end
        Cron->>Redis: SET digest:last-sent:v1:{userId}:{variant} EX 691200
    end
Loading

Comments Outside Diff (2)

  1. scripts/seed-digest-notifications.mjs, line 379-115 (link)

    P1 twice_daily sends only once per day

    The isDue() function has a logic flaw for twice_daily mode. It always checks localHour !== targetHour using the single digestHour value. Because there is only one target hour, the second send of the day can never fire — the cron will only trigger when localHour equals targetHour (e.g. 8), and the 11-hour minimum interval allows the next hit at that same hour the following day (24 h > 11 h). A second send at, say, 20:00 is never reached because 20 !== 8.

    In practice twice_daily behaves identically to daily. Either a second configurable hour (digestHour2) needs to be stored, or the second send time should be derived (e.g. targetHour + 12) and the hour check should accept either value:

    function isDue(rule, lastSentAt) {
      const nowMs = Date.now();
      const tz = rule.digestTimezone ?? 'UTC';
      const targetHour = rule.digestHour ?? 8;
      const localHour = toLocalHour(nowMs, tz);
    
      // For twice_daily, also accept targetHour + 12 as a valid send time
      const isTargetHour =
        localHour === targetHour ||
        (rule.digestMode === 'twice_daily' && localHour === (targetHour + 12) % 24);
    
      if (!isTargetHour) return false;
      // ... rest unchanged

    The schema and digestModeValidator would also need a corresponding update if a second hour field is introduced.

  2. scripts/seed-digest-notifications.mjs, line 608-611 (link)

    P1 Discord deactivation silently fails

    deactivateChannel(userId, 'discord') is called when a Discord webhook returns 404/410, but the /relay/deactivate Convex endpoint (in convex/http.ts lines 230–238) only accepts "telegram", "slack", or "email" as valid channelType values. Sending "discord" will always receive a 400 response, which is only logged as a warning. Dead Discord webhooks will therefore never be auto-deactivated.

    The existing relay deactivate validation in convex/http.ts needs to include "discord":

    // convex/http.ts  ~line 232
    (body.channelType !== "telegram" &&
     body.channelType !== "slack" &&
     body.channelType !== "email" &&
     body.channelType !== "discord")

Reviews (1): Last reviewed commit: "feat(digest): add daily digest notificat..." | Re-trigger Greptile

* 7. Updates digest:last-sent:v1:${userId}:${variant}
*/
import { createRequire } from 'node:module';
import { createHash } from 'node:crypto';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Unused import

createHash is imported from node:crypto but never referenced anywhere in the file.

Suggested change
import { createHash } from 'node:crypto';
// (remove this line entirely)

Comment on lines +50 to +64
async function upstashRest(...args) {
const res = await fetch(`${UPSTASH_URL}/${args.map(encodeURIComponent).join('/')}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${UPSTASH_TOKEN}`,
'User-Agent': 'worldmonitor-digest/1.0',
},
});
if (!res.ok) {
console.warn(`[digest] Upstash error ${res.status} for command ${args[0]}`);
return null;
}
const json = await res.json();
return json.result;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 upstashRest has no timeout

upstashPipeline guards its fetch with AbortSignal.timeout(15000), but upstashRest (used for ZRANGEBYSCORE, GET, and SET calls) has no timeout. A slow or hung Upstash connection would stall the entire cron process indefinitely. Consider adding the same guard:

Suggested change
async function upstashRest(...args) {
const res = await fetch(`${UPSTASH_URL}/${args.map(encodeURIComponent).join('/')}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${UPSTASH_TOKEN}`,
'User-Agent': 'worldmonitor-digest/1.0',
},
});
if (!res.ok) {
console.warn(`[digest] Upstash error ${res.status} for command ${args[0]}`);
return null;
}
const json = await res.json();
return json.result;
}
async function upstashRest(...args) {
const res = await fetch(`${UPSTASH_URL}/${args.map(encodeURIComponent).join('/')}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${UPSTASH_TOKEN}`,
'User-Agent': 'worldmonitor-digest/1.0',
},
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
console.warn(`[digest] Upstash error ${res.status} for command ${args[0]}`);
return null;
}
const json = await res.json();
return json.result;
}

Comment on lines +63 to +65
return json.result;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 digestHour has no range validation

The schema comment says // 0-23 local hour but neither the Convex validator (v.number()), the HTTP action, nor the edge function enforce that digestHour falls in 0–23. Storing digestHour: 100 would pass all validation and silently prevent every digest from ever firing (no hour will match 100). A bounds check should be added at the Convex mutation level:

// convex/alertRules.ts — setDigestSettings / setDigestSettingsForUser args
digestHour: v.optional(v.number()),

Consider replacing with a union of literals or adding an explicit check:

handler: async (ctx, args) => {
  if (args.digestHour !== undefined && (args.digestHour < 0 || args.digestHour > 23 || !Number.isInteger(args.digestHour))) {
    throw new ConvexError("digestHour must be an integer 0–23");
  }
  // ...

koala73 added a commit that referenced this pull request Apr 2, 2026
…ugh 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
Base automatically changed from feat/news-alerts-scoring to main April 2, 2026 16:46
koala73 added 8 commits April 2, 2026 22:04
- 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
… 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.
…Hour, 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.
…ation, 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
…ugh 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
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.
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
@koala73 koala73 force-pushed the feat/news-alerts-daily-digest branch from 184bb72 to d29dc39 Compare April 2, 2026 18:15
@koala73 koala73 merged commit c51717e into main Apr 2, 2026
5 of 6 checks passed
@koala73 koala73 deleted the feat/news-alerts-daily-digest branch April 2, 2026 18:17
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