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

TypeTriggerChannels Requested
event_publishedNew event published to guildin_app, web_push
event_updatedPublished event details changedin_app, email, web_push
event_starting_soonReminder before event start (cron)in_app, email, web_push
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, web_push
membership_role_changedGuild role changedin_app, web_push
membership_removedMembership endedin_app, web_push
battlenet_token_expiredBattle.net token needs re-authorizationin_app, web_push
friend_request_receivedAnother user sent a friend requestin_app, web_push
friend_request_acceptedRecipient accepted a sent friend requestin_app, web_push

"Channels Requested" reflects what each handler asks for at creation time. The actual channels delivered are then filtered by each user's NotificationPreference rows (see Preference Resolution). Handlers that don't pass an explicit channels list fall back to the default of in_app, web_push.

Event Handlers

Domain events are emitted by their respective services and caught by NotificationEventHandler (with the exception of friend.request.*, which are handled by FriendshipNotificationHandler in the friendship domain):

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
friend.request.sentFriend request addressee
friend.request.acceptedOriginal requester

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).

battlenet_token_expired is emitted directly by background jobs (RosterSyncService, GdlEnrichmentProgressService) when a Blizzard API call fails with an auth error — there is no corresponding domain event.

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.

The frontend renders title/body client-side using i18next. When a notification's payload.titleKey / bodyKey is set, useNotificationText (in NotificationBell.tsx / RecentNotifications.tsx) looks them up against the merged common namespace and passes any bodyParams.startTime / bodyParams.endTime ISO strings through Intl.DateTimeFormat so the user's active i18next language and browser timezone drive presentation. Notifications without keys fall back to the stored English title / body.

Email

Queued as pending delivery. NotificationDeliveryProcessor loads the recipient's User.preferredLocale and User.timezone, extracts titleKey / bodyKey / bodyParams from the notification payload, and passes all of it to MailService.sendNotificationEmail(). The mail service resolves the keys via I18nService.translate(key, params, locale, timezone) so the subject, body, action label, and footer all render in the user's preferred locale and IANA timezone (falls back to en / UTC when either 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.

Web Push

Mirrors the email pipeline. NotificationDeliveryProcessor resolves titleKey / bodyKey server-side using User.preferredLocale + User.timezone before handing translated strings to WebPushService.sendToUser(). The service worker (apps/web/public/sw-push.js) renders payload.title / payload.body as-is — no client-side translation in the SW. Notifications without keys fall back to the stored English fallback (used only by legacy types like battlenet_token_expired).

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

Localization & Timezone

Where notification copy lives

Title / body / action / footer strings live in the @toast-guilder/locales workspace package at packages/locales/src/notifications/<locale>.json. Both the API and the web app consume it:

ConsumerPathWhen it renders
APII18nService (server-side)Email send + web push send (delivery time, per recipient)
Web appi18n namespace common (merged in)In-app bell + Recent Notifications card (render time, browser)

How a recipient gets translated text

flowchart LR
Handler[Event handler] -->|"title/body=English fallback,<br/>payload.titleKey/bodyKey/bodyParams (ISO)"| DB[(Notification row)]
DB --> InApp[In-app render]
DB --> Email[Email delivery]
DB --> Push[Web push delivery]

InApp -->|browser TZ + i18next lang| ClientI18n[useNotificationText]
Email -->|preferredLocale + timezone| ServerI18n["I18nService.translate()"]
Push -->|preferredLocale + timezone| ServerI18n
ServerI18n --> SES[SES]
ServerI18n --> SW[sw-push.js]

bodyParams.startTime / endTime stay as raw ISO strings in the DB. They get formatted at delivery time with the recipient's locale + timezone, so a single notification row produces correctly localized output for every recipient.

User.timezone

Captured automatically on session bootstrap (RootLayout reads Intl.DateTimeFormat().resolvedOptions().timeZone and PATCHes /auth/me once when User.timezone is null). Users can override via the timezone picker on the Appearance settings page.

When User.timezone is null at delivery time the formatter falls back to UTC. The legacy notification.body fallback column is also formatted as UTC and suffixed with " UTC" (see notification-event.handler.ts).

Drift guards

Two CI checks keep the dictionary, the API, and the web app in lockstep:

  • API: scripts/check-notification-i18n-keys.js (lint:notification-keys) scans every titleKey: / bodyKey: literal under apps/api/src/ and fails if a key is missing from the English notification dictionary.
  • Web: apps/web/src/i18n/notifications.test.ts walks every leaf in NOTIFICATION_DICTIONARIES.en and asserts each resolves on every supported locale via i18next — catches misconfigured common-namespace merges.

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, the getUnreadCount RTK Query endpoint owns the WebSocket lifecycle via onCacheEntryAdded. While any component is subscribed to the unread count (i.e. the bell is mounted), a single /notifications socket is held open via lib/socket/socketFactory.ts. Incoming notification-created events invalidate the Notification LIST and UNREAD_COUNT tags so the bell's queries refetch automatically. A long-interval poll (5 min) runs as a safety net in case the socket drops across a reconnect. The socket is closed on logout via closeAllSockets().

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