Skip to main content

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

FieldDescription
idUUID
userIdRecipient
organizationIdGuild context (nullable for platform-wide)
typeNotificationType enum
titleNotification headline
bodyNotification body text
payloadJSON — arbitrary context (eventId, etc.)
readAtWhen user read it (nullable)
dismissedAtWhen user dismissed it (nullable)

Indexes: (userId, readAt), (userId, dismissedAt), (userId, organizationId).

NotificationDelivery

Audit log per channel per notification.

FieldDescription
notificationIdParent notification
channelin_app, email, or web_push
statuspending, sent, or failed
sentAtDelivery timestamp
failedAtFailure timestamp
errorMessageError detail on failure

NotificationPreference

Per-user opt-in/out for each notification type and channel.

FieldDescription
userIdUser
organizationIdGuild (nullable — null = global default)
typeNotificationType
channelNotificationChannel
enabledBoolean (default true)

Unique constraint: (userId, organizationId, type, channel).

PushSubscription

Browser push subscription endpoints per user (VAPID/Web Push).

FieldDescription
userIdUser
endpointPush service URL (unique)
p256dhClient public key
authClient auth secret

Notification Types

TypeTriggerDefault Channels
event_publishedNew event published to guildin_app
event_updatedPublished event details changedin_app, email
event_starting_soonReminder before event start (cron)in_app, email
event_cancelledEvent cancelledin_app, web_push
signup_invitedOfficer invited user to eventin_app, web_push
signup_confirmedOfficer/sync confirmed user for eventin_app, web_push
signup_waitlistedOfficer moved user to waitlistin_app, web_push
signup_benchedOfficer benched userin_app, web_push
application_submittedNew guild application receivedin_app, email, web_push
application_reviewedApplication accepted/declinedin_app, email
membership_role_changedGuild role changedin_app
membership_removedMembership endedin_app
battlenet_token_expiredBattle.net token needs re-authorizationin_app

Event Handlers

Domain events are emitted by their respective services and caught by NotificationEventHandler:

Domain EventRecipients
event.publishedAll active/trial guild members (excluding creator)
event.cancelledParticipants with status confirmed/waitlist
event.updatedParticipants with status confirmed/waitlist
signup.createdSignup user (when actor ≠ user)
membership.role.changedMember whose role changed
application.submittedOfficers with canManageMembers
application.reviewedApplicant
membership.removedRemoved 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:

  1. Load NotificationPreference rows for the user, type, and requested channels.
  2. Check org-specific preferences first, then global (where organizationId is null).
  3. Remove channels where enabled = false.
  4. 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 subscription
  • VAPID_PRIVATE_KEY — signing key
  • VAPID_SUBJECT — contact URL/email

API Endpoints

Notifications

MethodPathDescription
GET/notificationsPaginated list (query: organizationId, unreadOnly, page, pageSize)
GET/notifications/unread-countUnread count (query: organizationId)
PATCH/notifications/:id/readMark one read
PATCH/notifications/read-allMark all read
PATCH/notifications/:id/dismissDismiss one
PATCH/notifications/dismiss-allDismiss all

Preferences

MethodPathDescription
GET/notifications/preferencesGet user preferences
PUT/notifications/preferencesUpdate preferences

Web Push Endpoints

MethodPathDescription
GET/notifications/push/vapid-keyGet VAPID public key
POST/notifications/push/subscribeRegister subscription
DELETE/notifications/push/subscribeRemove subscription

Frontend Integration

RTK Query Hooks

  • useGetNotificationsQuery — paginated notification list
  • useGetUnreadCountQuery — badge/counter
  • useMarkNotificationReadMutation / useMarkAllReadMutation
  • useDismissNotificationMutation / useDismissAllNotificationsMutation
  • useGetNotificationPreferencesQuery / useUpdateNotificationPreferencesMutation
  • useGetVapidKeyQuery / 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

JobScheduleDescription
Event remindersEvery 5 minevent_starting_soon notifications