2 Commits

Author SHA1 Message Date
Elie Habib
20864f9c8a feat(settings): promote Notifications into its own tab (#3145)
* feat(settings): promote Notifications from settings accordion to its own tab

Notifications was buried at the bottom of the Settings accordion list, one
more click away than Panels/Sources. Since the feature is Pro-gated and
channel-heavy (Telegram pairing, Slack/Discord OAuth, webhook URL entry,
quiet hours, digest scheduling), a dedicated tab gives it the surface it
needs and makes the upsell visible to free users.

Extracts the notifications HTML and attach() logic into
src/services/notifications-settings.ts. Removes the inline block and now
unused imports (notification-channels, clerk, entitlements.hasTier,
variant, uqr, QUIET_HOURS/DIGEST_CRON rollout flags) from
preferences-content.ts. Adds a 'notifications' tab between Sources and
API Keys in UnifiedSettings (web only; desktop app keeps the old layout).

Rollout-flag tests (digest/quiet-hours) now read from the new module.

* perf(settings): lazy-attach Notifications tab to avoid eager fetch

Previously render() called notifs.attach() unconditionally, which fired
getChannelsData() on every modal open for Pro users even when they never
visited the Notifications tab. Mirrors the loadApiKeys() pattern: store
the render result in pendingNotifs and only call attach() when the tab
is first activated (or is the initial active tab). Cleanup on close,
destroy, and re-render remains unchanged.

Addresses greptile P2 on PR #3145.
2026-04-17 17:43:21 +04:00
Elie Habib
72a9801402 feat(quiet-hours): add quiet hours scheduling to notification alert rules (#2615)
* feat(quiet-hours): add quiet hours scheduling to notification alert rules

Users can now configure quiet hours on their alert rules to suppress or hold
non-critical notifications during specified times (e.g., 11pm-7am local time).

Three override modes:
- critical_only (default): only critical severity passes through; all others dropped
- silence_all: complete silence including critical alerts (shows warning in UI)
- batch_on_wake: non-critical events held in Redis (TTL 24h) and sent as a
  mini-digest when quiet hours end; critical still delivers immediately

Full stack: Convex schema + validators + mutations, relay/notification-channels
HTTP action, edge function, client service types.

Enforcement is in notification-relay.cjs: resolveQuietAction() is called per
rule before dispatch; held events RPUSH to digest:quiet-held:v1:{userId}:{variant}
and drainBatchOnWake() runs on each processEvent() call to flush held batches
for users whose quiet hours have ended.

* fix(quiet-hours): time-driven drain, anyDelivered guard, dedup on held events

P1: drainBatchOnWake was only called inside processEvent, so held events never
flushed when the queue was idle after quiet hours ended. Refactored to be
self-contained (fetches its own rules) and wired into the poll loop on a
5-minute timer, independent of queue activity.

P1: All four send helpers returned void so delivery failures were invisible.
sendTelegram/Slack/Discord/Email now return boolean (true=2xx success).
drainBatchOnWake uses anyDelivered pattern; DEL only fires on confirmed delivery.

P2: Held events bypassed checkDedup, allowing duplicate alerts to accumulate
in the Redis list. Added dedup check before holdEvent in the hold branch.

* fix(quiet-hours): flush held events when settings change away from batch_on_wake

Held alerts were silently expiring (24h TTL) if a user disabled quiet hours or
changed the override before the wake-up drain ran, because drainBatchOnWake
only processes rules still matching batch_on_wake + not-in-quiet-hours.

Fix: when set-quiet-hours is saved with quietHoursEnabled=false or any override
other than batch_on_wake, the edge function publishes flush_quiet_held to the
relay queue. The relay delivers the held batch immediately via drainHeldForUser,
then DELs the key. If there's nothing held, it's a no-op.

Extracted drainHeldForUser() helper to share logic between drainBatchOnWake
(timer-driven wake-up) and processFlushQuietHeld (settings-change path).

* fix(quiet-hours): respect rule channels in flush_quiet_held path

processFlushQuietHeld was passing null to drainHeldForUser which meant
"all verified channels", ignoring the rule's configured channel list.
A user with only telegram enabled could have held alerts delivered to
their slack or email after changing settings.

Fix: fetch the alertRule for the user+variant and pass rule.channels
as allowedChannels. Falls back to all verified channels only if the
rule fetch fails, preserving at-least-once delivery semantics.

* fix(quiet-hours): use public getByEnabled query in flush path

alertRules:getAlertRulesByUserId is an internalQuery — unreachable via
ConvexHttpClient from the relay. Switched processFlushQuietHeld to use
alertRules:getByEnabled (public) with an in-memory userId+variant filter,
matching the same query already used in drainBatchOnWake. On lookup
failure, return early (preserve held events) instead of falling back to
all channels.

* fix(quiet-hours): guard against null allowedChannels in flush path

When no matching enabled rule exists for the user+variant, or the rule
has an empty channels list, allowedChannels stays null. Without this
guard the call falls through to drainHeldForUser(null) which delivers
to all verified channels — defeating the channel-scoping contract.
Now return early and preserve held events for the next drain cycle.

* fix(quiet-hours): add QUIET_HOURS_BATCH_ENABLED guard, IANA timezone validation, and Quiet Hours UI

- notification-relay.cjs: add QUIET_HOURS_BATCH_ENABLED env flag (default on);
  when =0, batch_on_wake behaves as critical_only and drainBatchOnWake no-ops
- convex/alertRules.ts: validate quietHoursTimezone via Intl.DateTimeFormat so
  only valid IANA strings (e.g. America/New_York) are accepted; throw ConvexError
  with descriptive message on invalid input
- preferences-content.ts: add Quiet Hours section with enabled toggle, from/to
  hour selects, timezone text input, and override select; wired to setQuietHours
  with 800ms debounce; details panel shown/hidden on toggle change

Fixes gaps D, E, H from docs/plans/2026-04-02-003-fix-news-alerts-pr-gaps-plan.md

* fix(quiet-hours): hide batch_on_wake UI option when VITE_QUIET_HOURS_BATCH_ENABLED=0

When the relay flag disables batch-on-wake, the option was still visible in the
preferences UI. Users selecting it would silently get critical_only behaviour
instead. Gate the option on VITE_QUIET_HOURS_BATCH_ENABLED to keep UI and relay
contract in sync.

Addresses P2 review finding on #2615

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

Covers three paths flagged as untested by reviewers:
- VITE_QUIET_HOURS_BATCH_ENABLED gates batch_on_wake option rendering
- setQuietHours (public) validates quietHoursTimezone via validateQuietHoursArgs
- setQuietHoursForUser (internalMutation) also validates quietHoursTimezone
  to prevent silent bypass through the edge-to-Convex path
2026-04-02 20:46:59 +04:00