Notifications (in-app & email)

View on GitHub

Notifications (in-app & email)

Orbit tells users about activity two ways: an in-app notification (the bell dropdown) and an email, per App\Enums\Notifications\NotificationType — per-user, per-channel, independently toggleable in Account settings. This category documents that system: how a domain event becomes a notification, how per-user preferences gate delivery, and the one step that's easy to forget when adding a new kind of notification.

Guides, in the order you'd actually need them

  1. Add a new notification typeNotifications (in-app & email)Add a new notification typeWorked example: notifying a member when their project role changes — NotificationType::MemberRoleChanged. Today, ProjectMemberService::updateRole() only wr… — worked example adding MemberRoleChanged (notifying a member when their project role changes) end to end: the enum case, firing and handling the event, and — the step that's easy to skip — the frontend settings row that makes the new type visible and toggleable at all.

  2. Send a notification from your codeNotifications (in-app & email)Send a notification from your codeGuide 01 covers wiring a domain event through SendNotificationListener into NotificationService::notify(). You don't always need an event in between — noti… — the full NotificationService::notify() contract (recipient, type, severity, message, action URL, what each channel actually does), for when you want to send an existing notification type directly, without introducing a new domain event.

  3. Frontend ↔ backend wiring overviewNotifications (in-app & email)Frontend ↔ backend wiring overviewA map of the whole feature: how notifications reach the browser, how read/unread state round-trips, and how the settings tab persists preferences. Useful t… — how a notification actually reaches the browser (it's a shared Inertia prop, not a fetch), how mark-as-read/mark-all-as-read/delete round-trip, and how the settings tab persists a toggle — useful as a map before diving into either guide above.

  4. Add a dedicated transactional emailNotifications (in-app & email)Add a dedicated transactional emailGuide 02 covers reusing the generic NotificationMail template (one shared subject/body/action-button layout for every NotificationType). Sometimes that's t… — worked example (OwnershipTransferredMail) for the other shape of email: one that must go out unconditionally with its own subject and layout, bypassing NotificationType/NotificationSetting entirely — the same pattern ProjectInvitationMail already uses.

  5. Add @mention support to a free-text fieldNotifications (in-app & email)Add @mention support to a free-text fieldWorked example: the @mention system already built for issue comments — type @ in the comment box, pick a project member from a floating suggestion list, an… — worked example: the comment @mention system (IssueMentioned), end to end from typing @ in a plain <textarea> to the recipient's notification. Covers the reusable range-tracking technique (MentionRange/applyRangeEdit) that tells two mentions apart by character position instead of by name, and the backend check that stops a client from claiming mentioned_user_ids for someone the comment text never actually names.

  6. Clean up old notifications and activity historyNotifications (in-app & email)Clean up old notifications and activity historyNotifications and activity log entries pile up forever unless something removes them. Orbit gives every user two ways to do that, both in Settings → Notifi… — the per-user daily/weekly/monthly schedule (notifications:prune) and the one-off "delete older than N days" actions in Settings → Notifications: what is deleted, the due logic, the cache, and how to add an age preset, a frequency or another kind of data.

Wiring a domain event into the notification pipeline is itself documented as part of ../integrations/03-add-a-new-event-type.mdIntegrationsAdd a new event typeWorked example: adding IssueCreated — a kind of activity that doesn't exist yet. Today, creating an issue only fires IssueAssigned, and only if it's create… (using IssueCreated as its worked example) — guide 1 doesn't repeat that mechanic, it covers everything specific to notification types on top of it: the settings row, channel defaults, and the frontend/backend split that guide doesn't touch.

The architecture in one paragraph

Every notification-worthy fact is one NotificationType case (e.g. IssueAssigned, ProjectInvited). Every notification can go out on one or both of two NotificationChannels — InApp (on by default) and Email (off by default, see NotificationChannel::enabledByDefault()) — and a user can override either channel for either type via a NotificationSetting row (user_id + type + channel + enabled; no row means "use the channel's default"). NotificationService::notify() is the single funnel everything goes through: it always asks NotificationMailService to (maybe) send the email, then checks the recipient's in-app preference before writing a Notification row at all — skip the check and you'd persist rows for channels the user explicitly turned off. Domain events don't call notify() directly; SendNotificationListener::handle() is the one place that turns "this happened" (IssueAssigned, CommentAdded, ProjectInvited, …) into "tell this specific person, with this title/message" — see ../integrations/03-add-a-new-event-type.mdIntegrationsAdd a new event typeWorked example: adding IssueCreated — a kind of activity that doesn't exist yet. Today, creating an issue only fires IssueAssigned, and only if it's create… for how an event itself gets created and registered. On the frontend, AccountSettingsNotificationsTab.tsx's defaultNotificationTypes array is a hand-maintained mirror of the backend's NotificationType cases (icon, title, description per type) — nothing generates it from the enum, so a new backend type is invisible in Settings until you add its row there too.

The bell popup does not render the whole inbox: the shared prop carries only the newest 20 notifications plus the real unread count, and the popup pages older ones from GET /notifications (keyset cursor, unread and severity filters), with selection mode for bulk read/delete — see guide 3.