Notifications
Deliver in-app bell notifications, broadcasts, and unsubscribe handling with @repo/notifications, gated by config.notifications.
Two independent subsystems: the in-app bell (@repo/notifications, gated by config.notifications) for per-user alerts, and operator alerts (@repo/admin-notifications, gated by config.adminNotifications) that push business events to your chat tools. This page covers the bell; the alerts have their own page.
The web app and the desktop app both render the bell, the notifications list and the per-notification page, from the same backend and the same config.notifications gate, so a notification reaches a user wherever they are signed in. A notification's link names a web page; the desktop resolves it to its own screen where it has one and opens the web app in the browser where it does not.
Configuration
config.notifications is a discriminated union: { enabled: false } hides the bell entirely - createNotification returns null and writes no rows - or the enabled object tunes these knobs.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Master flag; false hides the bell entirely |
maxPerUser | number | 100 | Per-user row cap (trimmed only by inline cleanup) |
cleanupDays | number | 30 | Delete rows older than N days |
dropdownCharLimit | number | 100 | Max characters in the dropdown preview |
pollIntervalMs | number | 30000 | Unseen-count refetch interval |
fetchCooldownMs | number | 10000 | Minimum gap between client fetches |
broadcastConcurrency | number | 5 | concurrency.limit on the queued broadcast jobs |
broadcastConcurrency caps only the email and SMS broadcast jobs. In-app fan-out is unaffected - it writes rows sequentially in batches of 1,000.
Creating notifications
createNotification({ userId, type, title, message, link?, metadata? }) inserts one row and returns its ID, or null when the flag is off. Prefer the eight typed helpers in triggers.ts over raw inserts - each sets the correct type, a deep link, and HTML-escapes interpolated values via escapeHtml.
| Helper | Type | Links to |
|---|---|---|
notifyPlanChanged | plan_changed | /settings/billing |
notifyPlanRemoved | plan_changed | /settings/billing |
notifyCreditsAdded | credits_added | /settings/billing |
notifyMemberJoined | team_member_joined | /settings/organization |
notifyMemberLeft | team_member_left | /settings/profile |
notifyRoleChanged | team_role_changed | /settings/organization |
notifyPasswordChanged | password_changed | /settings/security |
notifySessionRevoked | session_revoked | /settings/security |
type must be one of the 18 NotificationType kinds in packages/config/src/types/notifications.ts: payment_success, payment_failed, plan_changed, credits_low, credits_added, subscription_expiring, team_invite_received, team_member_joined, team_member_left, team_role_changed, new_login, password_changed, two_factor_enabled, two_factor_disabled, session_revoked, system_announcement, maintenance_scheduled, feature_update.
Reading the bell
Service functions are all ownership-scoped by userId:
| Function | Purpose |
|---|---|
getNotifications(userId, { limit, offset }) | List non-dismissed, newest-first, with total |
getUnseenCount(userId) | Badge count (unseen + non-dismissed) |
markAsSeen(userId, ids | "all") | Clear the badge |
dismissNotification(userId, id) | Hide a single row |
The bell lives in the dashboard - see Data fetching for wiring its data.
Broadcasts
createBroadcast persists an announcement and fans out across the selected channels:
| Channel | Behavior |
|---|---|
| in-app | Only when config.notifications.enabled; batched inserts of 1,000. |
Queued as announcement/broadcast.email; renders the announcement marketing template with subject = title. Recipients are users who are not banned, have emailVerified, and have marketingOptIn. {userName} / {userEmail} are substituted per recipient. | |
| SMS | Queued as announcement/broadcast.sms, only when config.sms.enabled - see SMS for its recipient rules. |
Test send - sendTestAnnouncement (POST /admin/announcements/test) delivers a [TEST]-prefixed preview to the calling admin on the selected channels only, persisting no announcement row and firing no broadcast jobs. With the in-app channel selected it does insert one real notification row for that admin, so the preview appears in their own bell.
Unsubscribe links
Marketing email carries a signed link from generateUnsubscribeUrl(userId, config.baseUrl): HMAC-SHA-256 with BETTER_AUTH_SECRET, expiring after 30 days, verified in constant time by verifyUnsubscribeToken. It POSTs to /email/preference, which toggles marketingOptIn and emits user/marketing.disabled (cancels the re-engagement ladder) or user/marketing.enabled (resumes a paused onboarding sequence).
Announcement HTML
Titles and messages accept a small allow-list of formatting HTML, sanitized on write and again on render - so a message stored before a rule changed is still safe when it is read.
| Tags kept | p br strong b em i u ul ol li h1-h4 blockquote a |
| Attributes kept | href on <a>, and only http:, https:, mailto: or an empty value |
| Everything else | Dropped - including class, style, id, target and every on* handler |
- Other tags are unwrapped, not deleted:
<div>Hi</div>becomesHi, so pasted rich text keeps its words.script,style,xmp,titleandtemplatelose their contents with the tag; comments are removed. - Text that looks like markup is escaped, and sanitizing is idempotent - re-saving never double-escapes an
&. - Placeholders are substituted after sanitizing and escaped as text, so
{userName}can never inject markup.
Cleanup and retention
Two limits bound the table, applied by two different paths - so the per-user cap converges over time rather than instantly.
| Path | Enforces |
|---|---|
| Inline cleanup (a 1-in-10 sample of inserts) | cleanupDays age and maxPerUser for that user, trimmed newest-first |
Daily cron (0 4 * * *) | cleanupDays age only, globally - never maxPerUser |
See Background jobs.
Operator alerts
@repo/admin-notifications pushes signups, purchases, refunds, and deletions to your team chat, gated by config.adminNotifications (default { enabled: true }). notifyAdmins() is a no-op unless the flag is on, a provider is configured, and the event is not opted out.
| Provider | Required env |
|---|---|
| Slack | SLACK_WEBHOOK_URL |
| Discord | DISCORD_WEBHOOK_URL |
| Telegram | TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID |
Even with enabled: true, nothing delivers until one provider's credentials are set. The ten events, per-event opt-out, and delivery worker are covered in Admin notifications.