Notifications
The app tells users about changes to their account in-app: a bell in the
header and a /notifications page, updated live on every open tab and on
every API instance. This page covers:
- How it works (creation, the stream, fan-out between instances).
- How to add a notification type.
- Failure modes, and what the user sees.
- What production needs (reverse proxy, HTTP/2, PgBouncer).
- How to try it locally and in the tests.
Related docs:
- Background jobs: the mail queue the password-changed notice goes through.
1. How it works
The flow
PATCH /api/members/:userId/roles (or register, accept invitation, change or reset password) | +-> one Prisma transaction |- the business write (+ its audit event) |- INSERT notifications (type, data) NotificationsService.notify(tx, ...) |- SELECT pg_notify('notifications', '{"userId":"…","id":"…"}') '- password only: INSERT the mail.send job MailQueue.enqueue(tx, ...) | commit -> Postgres delivers the NOTIFY to every LISTENing connection rollback -> no row, no NOTIFY, no mail
every API instance: NotificationListener (one pg connection: LISTEN notifications) '- NotificationStreamHub.dispatch(userId, id) |- no stream for that user on this instance? nothing '- event: notification {id}, then event: unread-count {count}, to each of the user's streams
browser: NotificationsStore (HttpClient with fetch streaming) |- unread-count -> the bell's badge (and a list reload when already live: another tab changed rows) '- notification -> lists reload (the bell's menu if open, the /notifications page)There is no in-process shortcut: even on a single instance, an event reaches a stream only through Postgres. One instance and many therefore behave the same.
Marking read, read-all and dismissing (not reading) also send a pg_notify, with
{"userId":"…"} and no id. Every open tab of that user then gets a fresh
unread-count.
The pieces
| Piece | Where | Job |
|---|---|---|
Notification model | libs/core/db/prisma/core/notification.prisma | userId, type, data, readAt, plus the base fields. Dismissing is a soft delete. No text is stored. |
NOTIFICATION_TYPES, NotificationDataByType | libs/core/shared/src/lib/notification.ts | The contract: the union, the data of each type, and the stream event names. |
NotificationsService.notify | @core/api-notifications | Writes the row and runs pg_notify, in the caller’s transaction. |
NotificationsQueryService, the controller | same | List, count, read, read-all and dismiss, for the caller’s own notifications only. |
NotificationStreamHub | same | This instance’s open streams: heartbeat, expiry and fan-out. |
NotificationListener | same | One LISTEN connection, kept alive and reconnected with backoff. |
NotificationsStore | @core/notifications-data-access | The stream, reconnect, the polling fallback and the unread count. |
NotificationsBell, NotificationsPage | @core/notifications-feature | The UI, in el and en. |
Notifications belong to the user, not to an organization: the bell shows the same list whichever organization is active. A notification about organization data carries the organization’s id in data (ROLES_CHANGED does), so a client can switch to it before opening the linked page. A platform admin never switches (they have no active organization): the link opens directly.
The types today:
| Type | Recipient | Written by | data |
|---|---|---|---|
WELCOME | the new user | register, accept an invitation with a new account | {} |
ROLES_CHANGED | the member whose roles moved | PATCH /members/:userId/roles, only when added or removed is not empty | { added, removed, organizationId } (each entry a RoleRef plus the unit for a unit-scoped one) |
INVITATION_ACCEPTED | the inviter | accept invitation (new or existing account) | { email, userId, organizationId } |
PASSWORD_CHANGED | the user | profile password change, password reset (both also send mail) | { via: 'change' | 'reset' } |
TWO_FACTOR_ENABLED | the user | turning 2FA on in the profile (also mailed) | { via: 'self' | 'admin' } |
TWO_FACTOR_DISABLED | the user | turning 2FA off, or a platform admin’s 2FA reset (also mailed) | { via: 'self' | 'admin' } |
TWO_FACTOR_RECOVERY_CODE_USED | the user | a login with a recovery code (also mailed) | { remaining } (unused codes left) |
SIGN_IN_METHOD_ADDED | the user | linking Google to the account (also mailed) | { provider } |
SIGN_IN_METHOD_REMOVED | the user | unlinking Google (also mailed) | { provider } |
HELPDESK_TICKET_CREATED | staff (platform admins), not the reporter | create ticket | { ticketId, number, subject, category } |
HELPDESK_REPORTER_REPLIED | staff who took part, else all staff | reporter reply | { ticketId, number, subject } |
HELPDESK_STAFF_REPLIED | the reporter (also mailed) | public staff reply | { ticketId, number, subject } |
HELPDESK_STATUS_CHANGED | the reporter, unless they changed it (also mailed) | staff status change | { ticketId, number, subject, status } |
TEST | the caller | POST /api/notifications/test (development only, see Local setup and tests) | { sentAt } |
The stream
GET /api/notifications/stream, with the normal Authorization: Bearer header, answers text/event-stream:
event: unread-countdata: {"count":2}
event: notificationdata: {"id":"9b2f6c1e-…"}
: heartbeat
event: token-expireddata: {}unread-count: sent on connect, and after every change: a new notification, read, read-all or dismiss, including changes made in another tab.notification: sent for a new notification. Anunread-countfollows.: heartbeat: an SSE comment, every 25 s.token-expired: sent when the stream closes, one second after the access token expires (exp+ 1 s). The extra second matters: JWTexphas one-second granularity, so the client’s reconnect presents a token the guard rejects, and the interceptor turns the 401 into a refresh and a retry.- Errors: a missing or expired token is a normal JSON 401, because the guards run before any stream header. More than 10 streams for one user on one instance answer 429 (JSON, the same way).
- No replay: there is no
Last-Event-IDsupport. After a reconnect, the firstunread-countand a list reload bring the client up to date. - Never an error event: the server never turns a failure into an SSE
errorevent, because a database error can carry row values. Failures are logged and the event is skipped.
The browser reads the stream with fetch streaming: Angular’s HttpClient
with withFetch() and reportDownloadProgress, so the bearer interceptor and
the refresh-and-retry apply. It does not use EventSource, which cannot send
an Authorization header.
LISTEN/NOTIFY
- Channel and payload. Channel
notifications. The payload is{"userId":"…","id":"…"}for a new notification, or{"userId":"…"}for a count-only change. It carries ids only: no text, no data, nothing a subscriber could not look up.pg_notifyruns throughtx.$executeRaw, because it returnsvoid. - One connection per instance. The listener owns one dedicated
pgclient, outside Prisma’s pool, because LISTEN needs a session of its own. It is the only place in the libraries that importspgas a value (listen-client.ts). - Dead sockets. The client sets TCP keepalive (first probe after 10 s), and
the listener sends
SELECT 1every 30 s with a 5 s timeout. An idle LISTEN socket says nothing when it dies, so without the ping a half-open connection could go unnoticed for hours. Connect and LISTEN each time out after 5 s. - Backoff. A lost connection is replaced after 1, 2, 4, 8 and 16 s, then every 30 s. The backoff starts over only after a connection stayed up for 60 s, so one that drops right after LISTEN keeps backing off.
- Resync. After a reconnect, the hub sends a fresh
unread-countto every user with a stream on this instance, 10 queries at a time. A failed count is retried once. - Boot. The first
LISTENruns inonApplicationBootstrap. With no database, the API does not boot. - Shutdown (SIGTERM). In
beforeApplicationShutdown, the hub ends every stream, and the listener closes its connection (and destroys the socket ifend()takes more than 2 s). Without that, the HTTP server would wait for the streams forever. Clients reconnect, with backoff and jitter, to an instance that is still up. - Logs. The listener logs at
warnwhen it loses the connection or a reconnect fails, and atlogwhen the connection is back.
The client
- Start and stop. The bell renders only while the user is signed in (the
dashboard layout loads it lazily). It starts the store when it appears and
stops it when it goes.
stop()clears the count, and drops the results of any request from the earlier session. - Reconnect. After the stream ends, the store reconnects after 1, 2, 4 … 30 s, each jittered between half and all of that step, so tabs that lost the same server do not all return together. The backoff resets once a stream has stayed live for 60 s, as on the server.
- Polling. While the stream is down, the store polls
GET /api/notifications/unread-countevery 60 s (and once at once). - Dead connections. 60 s without a byte (more than two heartbeats) counts as a dead connection: the store drops it and reconnects.
token-expired. The store reconnects at once, with no backoff.- End of session. If the reconnect gets a 401 even after the interceptor’s refresh, the session is over: the store stops, and the auth flow signs the user out.
- Stability and
PendingTasks. The open stream is anHttpClientrequest, so it holds an AngularPendingTasksentry for as long as the user is signed in. The app therefore never reports stable (ApplicationRef.isStable,whenStable()). That matters for SSR, for the service worker’sregisterWhenStablestrategy, and forwhenStable()in tests: any spec that renders the bell must mockNotificationsStore. - Bundle. The bell and the page are lazy-loaded. Neither is in the initial
bundle, and nothing outside the lib imports
@core/notifications-featurestatically.
2. Adding a notification type
The example: TODO_SHARED, sent to a user someone shared a todo with.
-
The type and its data, in
libs/core/shared/src/lib/notification.ts:export const NOTIFICATION_TYPES = [..., 'TODO_SHARED'] as const;export interface NotificationDataByType {...TODO_SHARED: { todoId: string; title: string; byEmail: string };}Use plain JSON only, and never a secret: the owner can read the row. No migration is needed, because
typeis a string column. -
The
notify()call, inside the transaction that makes it true, after the business write and its audit event:await this.notifications.notify(tx, recipientId, 'TODO_SHARED', {todoId: todo.id,title: todo.title,byEmail: caller.email,});- Import
NotificationsModuleinto the feature module, and injectNotificationsService. The compiler checksdataagainst the type. - Add a unit test that the call happens with
tx, and that a failingnotifyfails the transaction. - If the type also needs an email, add a
MailJobtemplate and callmailQueue.enqueue(tx, …)next tonotify. Seepassword-changedfor an example.
- Import
-
The text, in el and en. In
libs/core/client/i18n/src/assets/i18n/{en,el}.json, undernotifications.types, add:"TODO_SHARED": "{{email}} shared “{{title}}” with you."Then add a case to
notificationText()(libs/core/client/notifications-feature/src/lib/utils/notification-text.ts) that returns that key and its params. It must stay total: readdatadefensively, as the existing cases do, because a row can come from a newer or older build. Itsdefaultcase returns the genericnotifications.types.unknown(“You have a new notification.”), which is also what an older client shows for a type it does not know.translations.specfails until both languages have the key with the same{{params}}. -
The click route. Add a line to
notificationLink()(notification-link.ts), for exampleTODO_SHARED: '/todos'. TodayROLES_CHANGEDandPASSWORD_CHANGEDgo to/user-profile, andINVITATION_ACCEPTEDto/members(of the organization indata.organizationId). Without a line, a click only marks the notification read. A notification whosedata.organizationIdis not the active organization switches to it first (injectOpenNotification,notification-open.ts, used by the bell and/notifications); a refused switch (no longer a member, suspended) is toasted and goes nowhere. For a platform admin nothing switches: the link opens as it is, and an/organization…link opens that organization’s screen (/organizations/<id>…). -
The icon. Add the type to
ICONSinnotification-icon.ts, which picks the lucide icon the bell’s panel shows. The map covers every type, so the build fails until the new one is there. A new icon also goes intoNOTIFICATION_ICONS. A type an older client does not know gets the bell.
3. Failure modes
| What breaks | What happens |
|---|---|
notify fails (a DB error) | The caller’s transaction rolls back: no change, no notification. The request fails as it would on any DB error. |
| The LISTEN connection drops | New notifications are still written. Streams on that instance get nothing until it reconnects (1–30 s), then they get a fresh count. A notification created during the gap is reflected only through that count resync: the badge corrects, and the lists show it when they next load. |
| An API instance stops | Its streams end. Clients reconnect to another instance, with backoff and jitter, and poll the count meanwhile. |
| A proxy buffers or cuts the stream | The client’s 60 s stall watchdog reconnects. Polling keeps the badge roughly right. |
| The access token expires | The stream ends with token-expired, and the client reconnects with a refreshed token. |
| The session is over (refresh fails) | The stream stops for good, and the auth flow signs the user out. |
| A user opens many tabs | Up to 10 streams per user per instance. The 11th answers 429, and that tab polls the count. |
4. Production
-
Reverse proxy. SSE needs buffering off, and a read timeout above the 25 s heartbeat. Nest already sends
X-Accel-Buffering: noandCache-Control: … no-transform. An nginx example:location /api/notifications/stream {proxy_pass http://api;proxy_http_version 1.1;proxy_set_header Connection '';proxy_buffering off;proxy_cache off;proxy_read_timeout 1h;gzip off;}Never compress
text/event-streamanywhere on the path. -
HTTP/2. Over HTTP/1.1, every open tab holds one of the browser’s roughly 6 connections per origin. Serve HTTP/2 at the proxy.
-
PgBouncer.
LISTENneeds a session. In transaction pooling mode it silently receives nothing. PointDATABASE_URLat Postgres directly, or at a session-mode pool. -
LISTEN gaps. Notifications created while an instance’s LISTEN connection is down are only reflected through the count resync after it reconnects. That is an accepted limit for v1.
-
Scale. Every instance receives every NOTIFY. For each one, it runs one unread-count query per affected user that has a stream on that instance. That is fine to start with. A busy deployment would batch the counts, or carry the count in the payload.
-
Sessions. An open tab keeps the session alive: each time the access token expires (15 min by default,
JWT_ACCESS_TTL), the stream reconnects through the refresh flow. The refresh token’s rotation and lifetime still apply, but a tab left open never goes idle on the server’s side.
5. Local setup and tests
There is nothing to configure: the API LISTENs on the same DATABASE_URL. To see it work:
- Run
pnpm nx serve keelmint-apiandpnpm nx serve keelmint-client. - Sign in as a user.
- In a second browser, sign in as a platform admin and change the first user’s roles.
- The bell updates without a reload.
To watch the raw stream:
curl -N -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:3000/api/notifications/streamThe tests:
pnpm nx test core-api-notifications:notify, the queries, the hub and the listener (with fake timers).pnpm nx run-many -t test -p core-client-notifications-data-access core-client-notifications-feature: the parser, the store (reconnect, polling, stall), the bell and the page.PORT=3001 pnpm nx e2e keelmint-api-e2e:notifications.spec.tscovers every trigger, the ownership 404s, and the live stream read withfetch. It also spawns a second API instance (port 3997, 3-second tokens), which receives an event triggered on the first and closes a stream at token expiry.
Testing live delivery in development
In development (isDevMode()), the bell’s panel has a Send test
notification button in its footer. It calls POST /api/notifications/test,
which writes a TEST notification for the signed-in user through the normal
notify() path (row and pg_notify in one transaction). The new item then
arrives through LISTEN, the hub and the stream like any other: the badge goes
up and the open panel reloads. The panel stays open, so you can watch it
arrive. Under the button, the panel says “Sent” or that sending failed. If it
says “Sent” and nothing arrives, the stream is the problem, not the write.
- The button. It shows only when the
DEV_MODEtoken (@core/common-data-access) is true. That defaults to Angular’sisDevMode(), so a production build never shows it. Specs override the token. - The route. It needs a signed-in user, notifies only that user, and allows
10 calls a minute per user. It is a self route (
@Authenticated()), so the permission catalog has nothing to grant. - Not in production. The controller lives in
NotificationsDevModule.CoreModuleimports it throughConditionalModule.registerWhen(NotificationsDevModule, devRoutesEnabled), which runs after the environment is loaded and validated. WithNODE_ENV=productionthe route is never registered. It is absent from the OpenAPI document, and a call gets a plain 404 before any guard runs, even without a token. A guard that answered 404 on a registered route would still publish the route, and would rely on one check that a refactor could drop. The handler also throws a 404 whenNODE_ENVisproduction, as a second line in case the module is ever imported directly. The e2e suite checks the 404 against its production instance (port 3997). - Set
NODE_ENV=productionin production. The route is registered wheneverNODE_ENVis anything butproduction, and the env schema defaultsNODE_ENVtodevelopment. A production deploy that leaves it unset therefore exposes the route. The shipped Dockerfile setsENV NODE_ENV=production, but a deploy that does not use it must set the variable itself.
From a terminal:
curl -X POST -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:3000/api/notifications/test6. The password-changed email
A password change or reset also queues a password-changed mail (el/en,
according to the user’s language) in the same transaction, through
MailQueue.enqueue. It says the password was changed and that other sessions
were signed out. Its only link is ${APP_URL}/auth/forgot-password, a plain
page that carries no token, so the mail holds nothing an attacker who reads it
could use. If the transaction rolls back, there is no mail either.