GenerateSaaS

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.

KeyTypeDefaultDescription
enabledbooleantrueMaster flag; false hides the bell entirely
maxPerUsernumber100Per-user row cap (trimmed only by inline cleanup)
cleanupDaysnumber30Delete rows older than N days
dropdownCharLimitnumber100Max characters in the dropdown preview
pollIntervalMsnumber30000Unseen-count refetch interval
fetchCooldownMsnumber10000Minimum gap between client fetches
broadcastConcurrencynumber5concurrency.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.

HelperTypeLinks to
notifyPlanChangedplan_changed/settings/billing
notifyPlanRemovedplan_changed/settings/billing
notifyCreditsAddedcredits_added/settings/billing
notifyMemberJoinedteam_member_joined/settings/organization
notifyMemberLeftteam_member_left/settings/profile
notifyRoleChangedteam_role_changed/settings/organization
notifyPasswordChangedpassword_changed/settings/security
notifySessionRevokedsession_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:

FunctionPurpose
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:

ChannelBehavior
in-appOnly when config.notifications.enabled; batched inserts of 1,000.
emailQueued 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.
SMSQueued 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.

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 keptp br strong b em i u ul ol li h1-h4 blockquote a
Attributes kepthref on <a>, and only http:, https:, mailto: or an empty value
Everything elseDropped - including class, style, id, target and every on* handler
  • Other tags are unwrapped, not deleted: <div>Hi</div> becomes Hi, so pasted rich text keeps its words. script, style, xmp, title and template lose 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.

PathEnforces
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.

ProviderRequired env
SlackSLACK_WEBHOOK_URL
DiscordDISCORD_WEBHOOK_URL
TelegramTELEGRAM_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.

On this page