GenerateSaaS

Notifications

Deliver in-app bell notifications and operator alerts, both flag-gated and queue-backed.

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.

In-app notifications

@repo/notifications powers the bell, badge, and /notifications page, served through @repo/api. Gated by config.notifications.enabled (default true) - when off, createNotification returns null, writes no rows, and the bell is hidden.

Configuration

config.notifications is a discriminated union: { enabled: false } turns it off, 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. type must be one of the 18 NotificationType kinds (packages/config/src/types/index.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.

Prefer the seven 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
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

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; wire its data up per your framework - see Data fetching.

Broadcasts and unsubscribe

createBroadcast persists an announcement and fans out across the selected channels:

  • in-app: only when config.notifications.enabled; batched inserts of 1,000.
  • email: 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} placeholders in the title and message are substituted per recipient.
  • SMS: queued as announcement/broadcast.sms, only when config.sms.enabled - see SMS for its recipient eligibility.

Test send - sendTestAnnouncement (POST /admin/announcements/test) delivers a [TEST]-prefixed preview to the calling admin across the selected channels only. It persists no announcement row and fires no broadcast jobs.

Marketing email carries a signed unsubscribe link from generateUnsubscribeUrl(userId, config.baseUrl). The token is HMAC-SHA-256 signed with BETTER_AUTH_SECRET, expires after 30 days, and is verified in constant time by verifyUnsubscribeToken. The link POSTs to /email/preference, which toggles marketingOptIn and emits user/marketing.disabled (cancels the re-engagement email) or user/marketing.enabled (resumes a paused onboarding sequence).

Cleanup and retention

Two limits bound the table, applied by two different paths:

  • Inline cleanup: runs on ~10% of inserts; deletes rows older than cleanupDays and trims rows beyond maxPerUser (newest-first).
  • Daily cron: deletes globally by cleanupDays age only; it does not enforce maxPerUser. See Background jobs.

Admin notifications

@repo/admin-notifications pushes operator alerts (signups, purchases, refunds, deletions) to Slack, Discord, and/or Telegram. Gated by config.adminNotifications (default { enabled: true }). notifyAdmins() is a no-op unless the flag is on, at least one provider is configured, and the event isn't opted out.

Providers auto-select from env vars - nothing sends until at least one set is present:

ProviderRequired env
SlackSLACK_WEBHOOK_URL
DiscordDISCORD_WEBHOOK_URL
TelegramTELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID

Even with enabled: true, alerts deliver nothing until at least one provider's credentials are set in Environment variables.

There are 10 AdminNotificationEvent kinds, fired from @repo/auth and payment webhooks (see Payments): user_signup, subscription_purchase, lifetime_purchase, plan_upgrade, plan_downgrade, subscription_cancel, refund, product_purchase, credits_purchase, account_deleted.

Opt individual events out via the optional events map (only false suppresses):

// packages/config/src/index.ts
adminNotifications: {
  enabled: true,
  events: { refund: false } // suppress refund alerts; all others stay on
}

Call sites invoke notifyAdmins({ event, data }), which emits an admin-notification/send job. The job re-checks the flag, resolves the entity via getEntityInfo from @repo/audit, then fans out to every configured provider with Promise.allSettled.

Frequently asked questions

Why isn't my admin alert showing up even though enabled is true? No provider env vars are set. notifyAdmins is a no-op until at least one of Slack/Discord/Telegram has credentials.

Do disabled notifications still hit the database? No. With config.notifications.enabled: false, createNotification short-circuits to null before any insert, and broadcasts skip the in-app channel.

Why did old notifications survive past maxPerUser? The cron enforces only cleanupDays. The maxPerUser cap is trimmed exclusively by the inline path, which samples ~10% of inserts - so it converges over time rather than instantly.

On this page