Notifications
Notifications
What it does
A generalized dispatcher for "tell a user something happened" — always writes
an in-app notification (the bell in the app header), and optionally also
sends an email, gated by a per-user preference the user controls themselves
in /settings.
How to configure it
No new env vars — this reuses RESEND_API_KEY/EMAIL_FROM from
email.md for its email half (a notify() call with no email
option skips that entirely, no config needed at all for in-app-only events).
How it works
lib/notify.ts'snotify({ userId, type, title, body?, link?, email? })is the one call site every "tell a user something" flow goes through:- Always inserts a row into the
notificationstable (lib/db/schema/app.ts—typeis free-form dot-namespaced text, same convention aslib/audit.ts'saction, since the two overlap heavily: a role change or a new subscription is both audit-worthy and notification-worthy). - If
emailis present, looks upnotification_preferences.emailEnabledfor that user (defaults totrue— the table only gets a row once a user actually changes the setting, seegetEmailPreference/setEmailPreference) and sends it vialib/email.ts'ssendEmail()if still enabled. Best-effort throughout — same non-critical error handling aslib/audit.ts'slogAudit, a notification failing to send must never break the action that triggered it.
- Channels are a closed set:
NotifyChannelis"db" | "email". Widen the type and the dispatch logic innotify()to add another.
- Always inserts a row into the
- Call sites (
lib/auth.ts) — the events that currently notify:organizationHooks.afterUpdateMemberRole— in-app only (there's no email template for this event yet).organizationHooks.afterCreateInvitation— in-app only, and only when the invited email already belongs to auserrow (an invitee with no account has nowhere to put a bell notification; they still get the invitation email regardless, viasendInvitationEmail, unconditionally).- Stripe
onSubscriptionComplete/onSubscriptionCancel— in-app + email, reusing theSubscriptionStartedEmail/SubscriptionCanceledEmailtemplates from email.md. Routing them throughnotify()rather than callingsendEmail()directly is what makes the email half respect the user's preference. Each also fans out an in-app-only notification to every site admin vianotifyAdmins()(lib/auth.ts).
components/notifications/notification-bell.tsx— the bell + dropdown inapp/(app)/layout.tsx's header. The layout fetches the 10 most recent notifications + an unread count per request and passes them down; clicking an item callsmarkNotificationRead()(lib/actions/notifications.ts) and navigates to itslinkif it has one. Polling, not websockets: a 20ssetInterval(() => router.refresh())re-runs the layout's server-side fetch so a notification created by another request (a webhook, an admin action) shows up without a manual reload.app/(app)/settings/page.tsx'sNotificationPreferencesForm— the one user-facing toggle,emailEnabled. In-app notifications are never optional (that's the whole point of the bell); this only controls whether matching events also email.
How to extend it
- A new notification-worthy event — call
notify()from wherever it happens (a Server Action, a better-auth hook), same as the call sites above. Passemail: { subject, react }only if you also want it emailed — build the React Email template the same way asemails/*.tsx(see email.md). - Per-notification-type email preferences (instead of one global
toggle) — widen
notification_preferencespast its singleemailEnabledcolumn;getEmailPreference/setEmailPreferenceinlib/notify.tsare the only two places that would need to change shape. - Push-based delivery (SSE/WebSocket instead of polling) — replace the
setIntervalinnotification-bell.tsx; the server-side data shape (notificationstable, the layout's fetch) doesn't need to change.
How to remove it
Delete lib/notify.ts + its test, lib/actions/notifications.ts,
components/notifications/,
components/settings/notification-preferences-form.tsx, the notify()/
notifyAdmins() calls and notifyAdmins helper from lib/auth.ts
(the logAudit() calls next to them are independent — see
security.md — leave those), the notifications/notification_preferences tables from
lib/db/schema/app.ts (regenerate a migration), and the bell + its data
fetch from app/(app)/layout.tsx.