koala73 / koala73/worldmonitor

feat(alerts): free-tier alert ladder — starter quota (critical-only, ≤3 countries, web push) with capability-based entitlement gating

Open
#4,273 0 comments 0 reactions 0 assignees View on GitHub
area: infrastructure feature P1
Dominant language
TypeScript
Stars
86.6k
Forks
13.1k
Avg merge
8h 4m
Merged PRs (30d)
825

Description

## Summary

Introduce a **free-tier alert ladder**: a small, deliberately-limited alert quota (critical-only, ≤3 countries, web-push/email) for free users, with Pro keeping unlimited countries, all channels, all sensitivities and digest modes. Replace the binary `tier < 1 → PRO_REQUIRED` gate with capability-based entitlement checks.

## Why (evidence)

- Free tier currently has **zero retention hook** — a visitor during a crisis spike has no reason to return tomorrow. Flightradar24's history (MH17, Eyjafjallajökull) shows crisis spikes only convert when a persistent artifact exists (install / alert subscription).
- Alert-capacity ladders are the proven conversion gate for real-time dashboards: TradingView free 3 alerts → Essential 20 → Premium 400; Healthchecks.io gates check count + channels; Liveuamap premium = alerts + history; FR24 Gold = extended data.
- Habit forms first, payment follows: a free user with 3 critical alerts on followed countries self-selects into the sticky cohort, then hits the wall naturally (more countries, Slack/Discord/Telegram/webhook channels, high/all sensitivity, realtime digests).
- Complements #3936 (severity taxonomy) and #3935 (category filters) — together they answer the #1 complaint about enterprise incumbents (Dataminr-class alert fatigue, 300–500 alerts/day).

## Current state (verified anchors)

- `convex/config/productCatalog.ts` — `PlanFeatures` has `tier`, `maxDashboards`, `apiAccess`, `apiRateLimit`, `exportFormats`, `mcpAccess`. **No alert-related fields exist.**
- `convex/alertRules.ts:36-55` — `assertProEntitlement()` throws `ConvexError({ code: "PRO_REQUIRED" })` when `tier < 1`. Called by `setAlertRules`, `setDigestSettings`, `setQuietHours`, `setNotificationConfigForUser`.
- `api/notification-channels.ts:219-226` — second gate: `if (!ent || ent.features.tier < 1)` → 403 `pro_required` on every POST.
- Alert rule schema (one row per `userId`+`variant`): `enabled`, `eventTypes[]`, `sensitivity ("all"|"high"|"critical")`, `channels[]` (incl. `web_push`), `digestMode`, `quietHours*`, `countries?: string[]` (≤50, ISO-3166 alpha-2).
- Delivery constraint already enforced: realtime is **critical-only** (`assertCompatibleDeliveryMode` rejects `(realtime, all/high)`) — this maps perfectly onto a free tier that is critical-only.
- Web push infra **already exists end-to-end**: `src/services/push-notifications.ts` (VAPID subscribe → `set-web-push`), relay sends push at delivery time; digest cron (`scripts/seed-digest-notifications.mjs`, Railway, every 30 min) sends email/telegram/slack/discord.
- UI lock: `src/services/notifications-settings.ts:70-109` — `isPro = hasTier(1)`, else renders `panel-locked-cta` upsell.

## Proposed design

New per-tier entitlement fields in `PlanFeatures` (flat, matching existing style):

| Field | Free | Pro | API/Ent |
|---|---|---|---|
| `alertMaxCountries` | 3 | 50 | 50 |
| `alertChannels` | `["web_push","email"]` | all | all |
| `alertSensitivities` | `["critical"]` | all | all |
| `alertDigestModes` | `["realtime","daily"]` | all | all |
| `aiDigestEnabled` capability | false | true | true |

Free = "get pinged when something *critical* happens in up to 3 countries you follow." AI-composed digests, Slack/Discord/Telegram/webhook channels, high/all sensitivity stay Pro — preserving the upgrade reason while giving free users the retention loop.

## Implementation plan

**Phase 1 — entitlement plumbing**
- [ ] Add the 4 alert fields to `PlanFeatures` + all feature sets in `convex/config/productCatalog.ts`
- [ ] Verify merge logic in `convex/entitlements.ts` (`FREE_TIER_DEFAULTS` for unauthenticated/no-row users) carries new fields
- [ ] Tests: `convex/__tests__/entitlements.test.ts`, `server/__tests__/entitlement-check.test.ts`, `tests/entitlement-transition.test.mts`, watchdog parity tests

**Phase 2 — capability-based gating (server)**
- [ ] Replace `assertProEntitlement` in `convex/alertRules.ts` with `assertAlertCapability(ctx, userId, requestedConfig)` validating countries count, channels, sensitivity, digestMode against entitlements; return structured error per violated capability (`ALERT_LIMIT_COUNTRIES`, `ALERT_CHANNEL_LOCKED`, …) so the UI can render targeted upsells
- [ ] Mirror in `api/notification-channels.ts` POST gate (signed-in free users pass; capability violations → 403 with `upgrade` hints; keep SSRF checks untouched)
- [ ] Note: internal migration mutations (`setAlertRulesForUser` etc.) stay ungated; audit previously-blocked free rows

**Phase 3 — UI**
- [ ] `src/services/notifications-settings.ts`: replace the all-or-nothing lock with the free config surface; locked options rendered disabled with inline `panel-locked-cta` per option
- [ ] Post-follow prompt: after a user follows a country (`src/services/followed-countries.ts`), offer "Get critical alerts for {country}" → permission prompt → `subscribeToPush()`
- [ ] i18n keys for new copy (use `scripts/sync-locale-keys.mjs` — order-preserving)

**Phase 4 — delivery & cost guards**
- [ ] Confirm relay/digest cron honor free-tier configs (critical-only realtime web push is the main free path; email daily digest = Resend cost — consider launching free with web_push only and adding email after volume check)
- [ ] Per-user delivery rate cap for free tier (defense vs. notification storms during major events)

**Phase 5 — docs & pricing surfaces**
- [ ] Update `/pro` pricing comparison, docs features page, and product catalog descriptions

## Acceptance criteria

- Signed-in free user can enable critical-only alerts for ≤3 countries via web push without seeing a paywall
- 4th country / Slack channel / "high" sensitivity attempts return structured upgrade errors and render inline CTAs
- Pro behavior unchanged; entitlement transition tests green; no regression in `assertCompatibleDeliveryMode`
- Full `npm run typecheck` green (not just touched tests)

## Out of scope

- Mobile native push (no native apps yet)
- Per-event-type granular quotas
- Anonymous (signed-out) alerts — requires account; the prompt funnels sign-ups

## Effort

~3–5 days. Highest-leverage item from the 2026-06-10 adoption research: infra already exists, this is mostly re-gating + UI.

Contributor guide

Open the contributing guide

Research direction

Start with convex/config/productCatalog.ts and convex/entitlements.ts, then trace the existing gates in convex/alertRules.ts and api/notification-channels.ts. Review the entitlement and transition tests listed in the issue before changing the UI in src/services/notifications-settings.ts and src/services/followed-countries.ts. Done means free users can configure critical web-push alerts for up to three countries, restricted options return targeted upgrade errors, Pro behavior is unchanged, and npm run typecheck passes.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, backend, frontend
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.