Notification Pipeline
Event-driven notification system supporting in-app, email, and web push delivery channels with per-user, per-guild preference control.
Architecture Overview
flowchart TD
A["Domain Event (e.g. event.published)"] --> B[NotificationEventHandler]
B -->|determines recipients + channels| C[CreateNotificationUseCase]
C -->|checks preferences, filters disabled channels| D{Channels enabled?}
D -->|No| E[Skip — no notification created]
D -->|Yes| F["Create Notification + Delivery rows"]
F -->|in_app → sent immediately| G[NotificationCreatedEvent]
F -->|email/web_push → pending| G
G -->|"@OnEvent"| H[NotificationDeliveryProcessor]
H --> I[MailService.sendNotificationEmail]
H --> J[WebPushService.sendToUser]
Data Model
Notification
| Field | Description |
|---|---|
id | UUID |
userId | Recipient |
organizationId | Guild context (nullable for platform-wide) |
type | NotificationType enum |
title | Notification headline |
body | Notification body text |
payload | JSON — arbitrary context (eventId, etc.) |
readAt | When user read it (nullable) |
dismissedAt | When user dismissed it (nullable) |
Indexes: (userId, readAt), (userId, dismissedAt), (userId, organizationId).
NotificationDelivery
Audit log per channel per notification.
| Field | Description |
|---|---|
notificationId | Parent notification |
channel | in_app, email, or web_push |
status | pending, sent, or failed |
sentAt | Delivery timestamp |
failedAt | Failure timestamp |
errorMessage | Error detail on failure |
NotificationPreference
Per-user opt-in/out for each notification type and channel.
| Field | Description |
|---|---|
userId | User |
organizationId | Guild (nullable — null = global default) |
type | NotificationType |
channel | NotificationChannel |
enabled | Boolean (default true) |
Unique constraint: (userId, organizationId, type, channel).
PushSubscription
Browser push subscription endpoints per user (VAPID/Web Push).
| Field | Description |
|---|---|
userId | User |
endpoint | Push service URL (unique) |
p256dh | Client public key |
auth | Client auth secret |
Notification Types
| Type | Trigger | Default Channels |
|---|---|---|
event_published | New event published to guild | in_app |
event_updated | Published event details changed | in_app, email |
event_starting_soon | Reminder before event start (cron) | in_app, email |
event_cancelled | Event cancelled | in_app, web_push |
signup_invited | Officer invited user to event | in_app, web_push |
signup_confirmed | Officer/sync confirmed user for event | in_app, web_push |
signup_waitlisted | Officer moved user to waitlist | in_app, web_push |
signup_benched | Officer benched user | in_app, web_push |
application_submitted | New guild application received | in_app, email, web_push |
application_reviewed | Application accepted/declined | in_app, email |
membership_role_changed | Guild role changed | in_app |
membership_removed | Membership ended | in_app |
battlenet_token_expired | Battle.net token needs re-authorization | in_app |
Event Handlers
Domain events are emitted by their respective services and caught by NotificationEventHandler:
| Domain Event | Recipients |
|---|---|
event.published | All active/trial guild members (excluding creator) |
event.cancelled | Participants with status confirmed/waitlist |
event.updated | Participants with status confirmed/waitlist |
signup.created | Signup user (when actor ≠ user) |
membership.role.changed | Member whose role changed |
application.submitted | Officers with canManageMembers |
application.reviewed | Applicant |
membership.removed | Removed member |
The event_starting_soon type is handled differently — EventSchedulerService.sendEventReminders() runs on a 5-minute cron and creates notifications for participants of events starting within EVENT_REMINDER_LEAD_MINUTES (default 15).
Preference Resolution
When CreateNotificationUseCase runs:
- Load
NotificationPreferencerows for the user, type, and requested channels. - Check org-specific preferences first, then global (where
organizationIdis null). - Remove channels where
enabled = false. - If no channels remain, skip — no notification is created.
This means users can disable email notifications globally but re-enable them for a specific guild.
Delivery Channels
In-App
Marked sent immediately on creation. Retrieved via GET /notifications (list) and GET /notifications/unread-count. The bell keeps itself current through a WebSocket gateway — see Realtime Delivery below.
Email
Queued as pending delivery. NotificationDeliveryProcessor loads the recipient's User.preferredLocale, extracts titleKey/bodyKey/bodyParams from the notification payload, and passes all of it to MailService.sendNotificationEmail(). The mail service resolves the keys via I18nService so the subject, body, action label, and footer all render in the user's preferred locale (falls back to en when preferredLocale is null). If a payload has no i18n keys (legacy/custom callers), the raw English subject/body is used. On success → sent; on failure → failed with error message.
Backend locale resources live in apps/api/src/i18n/locales/*.json and mirror the notifications.types + notifications.actions + notifications.emailFooter subtrees of apps/web/src/locales/<locale>/common.json. A drift test (i18n.service.spec.ts) fails CI if the backend copy falls behind. A follow-up task tracks extracting a shared @toast-guilder/locales workspace package to eliminate the duplication.
Web Push
Uses VAPID protocol via the web-push library. WebPushService.sendToUser() sends to all registered PushSubscription endpoints for the user. Invalid subscriptions (404/410 responses) are automatically removed.
Configuration:
VAPID_PUBLIC_KEY— shared with frontend for subscriptionVAPID_PRIVATE_KEY— signing keyVAPID_SUBJECT— contact URL/email
API Endpoints
Notifications
| Method | Path | Description |
|---|---|---|
| GET | /notifications | Paginated list (query: organizationId, unreadOnly, page, pageSize) |
| GET | /notifications/unread-count | Unread count (query: organizationId) |
| PATCH | /notifications/:id/read | Mark one read |
| PATCH | /notifications/read-all | Mark all read |
| PATCH | /notifications/:id/dismiss | Dismiss one |
| PATCH | /notifications/dismiss-all | Dismiss all |
Preferences
| Method | Path | Description |
|---|---|---|
| GET | /notifications/preferences | Get user preferences |
| PUT | /notifications/preferences | Update preferences |
Web Push Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /notifications/push/vapid-key | Get VAPID public key |
| POST | /notifications/push/subscribe | Register subscription |
| DELETE | /notifications/push/subscribe | Remove subscription |
Frontend Integration
RTK Query Hooks
useGetNotificationsQuery— paginated notification listuseGetUnreadCountQuery— badge/counteruseMarkNotificationReadMutation/useMarkAllReadMutationuseDismissNotificationMutation/useDismissAllNotificationsMutationuseGetNotificationPreferencesQuery/useUpdateNotificationPreferencesMutationuseGetVapidKeyQuery/useSubscribePushMutation/useUnsubscribePushMutation
Realtime Delivery
NotificationGateway (namespace /notifications) authenticates each socket via the better-auth session cookie on connect and auto-joins a user:${userId} room. When the notification.created domain event fires, the gateway emits notification-created to that user's room, carrying only { notificationId, type, organizationId }.
On the frontend, useNotificationRealtime() listens for that event and invalidates the Notification LIST and UNREAD_COUNT RTK Query tags — the bell's existing queries refetch automatically. A long-interval poll (5 min) runs as a safety net in case the socket drops across a reconnect.
Security: the client never claims a user id. The gateway derives it from the session cookie, so one user's socket can never receive another user's notifications.
Cron Jobs
| Job | Schedule | Description |
|---|---|---|
| Event reminders | Every 5 min | event_starting_soon notifications |