Feature: In-App Notifications (AppNotification pipeline)
The notification pipeline delivers in-app notifications to platform users (AMs, Experts, Org members, widget visitors) over two coordinated surfaces: a persistent REST store (/notifications/*) and a real-time Socket.io push (/notifications namespace). A notification is written to Postgres once and pushed to the recipient's live sockets in the same create() call, so the bell badge updates instantly and the history survives logout/reconnect.
Shipped by PR #915 (feat(notifications): AppNotification REST + Socket.io delivery pipeline), closing #170.
Scope note. This is the in-app notification surface (the bell +
/client/inbox). It is distinct from transactional email (Resend / GSuite-DWD) and from the Expert-queue real-time events, which share the same Socket.io gateway but are a different product surface โ see expert-queue.md.
Pipeline overviewโ
Internal caller (billing notifier, workspace job, โฆ)
โ NotificationsService.create({ userId, type, message, title?, meta? })
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 1. INSERT into app_notifications (recipient_user_id, โฆ) โ โ persistent
โ 2. toNotificationDto(saved) (API-shaped field names) โ
โ 3. gateway.emitToUser(userId, "notification:new", dto) โ โ real-time
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โผ REST โผ Socket.io
GET /notifications (history, paginated) "notification:new" โ user:<userId> room
PATCH /notifications/:id/read (Redis pub/sub fans out across pods)
โ โ
โผ โผ
Frontend NotificationContext (dedupe by id) โ bell badge / inbox
Key property: the wire shape is identical on both surfaces. The persistence entity uses richer column names (recipientUserId, eventType, body); toNotificationDto() maps them to the public contract (userId, type, message) so the Socket.io payload and the REST response are byte-for-byte the same DTO.
Data modelโ
AppNotification entity โ api/src/onboarding/onboarding.entities.ts. Table app_notifications.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | generated on insert |
recipient_user_id | text | the user who receives the notification; indexed |
event_type | varchar(100) | the notification type (e.g. billing.payment_failed) โ exposed as type on the wire |
title | text, nullable | optional headline |
body | text, nullable | message body โ exposed as message on the wire |
meta | jsonb, nullable | metadata bag (org id, slug, conversation id, invoice id, โฆ) |
read_at | timestamptz, nullable | null = unread; set once on first mark-read |
created_at | timestamptz | @CreateDateColumn, never updated; indexed |
Indexes: recipientUserId, readAt, createdAt โ sized for the three hot query shapes (filter by recipient, unread filter, order by recency).
meta is true jsonb, not simple-json. Migration 1799000000007-AlterAppNotificationsMetaToJsonb converted it pre-emptively: simple-json heals back to text under synchronize, which breaks any raw jsonb SQL (COALESCE(meta,'{}'::jsonb) || โฆ) with "COALESCE types text and jsonb cannot be matched". Table created by 1714000000021-AppNotifications.
Isolation (ADR-020)โ
AppNotification is user-scoped, not Org ร Specialist scoped โ it is a per-recipient inbox, not a customer-PII row. Enforcement lives at the service-query layer:
findAllForUser(userId)always filtersrecipient_user_id = :userId. The controller resolvesuserIdfrom the JWT (req.user.id ?? userId ?? sub) and throwsUnauthorizedExceptionif it is missing โ otherwisefindAllForUser(undefined)would degrade to "no filter" and leak across users (Greptile P1, #915).markRead(id, requestingUserId)re-checks ownership and throwsForbiddenExceptionwhenrecipient_user_id !== requestingUserId.- The Socket.io path is room-scoped: a notification is only emitted to
user:<recipientUserId>, and a socket only joins its ownuser:<id>room (derived from its verified JWT). No cross-user fan-out.
REST surfaceโ
Source: api/src/notifications/notifications.controller.ts. Prefix /notifications. All routes JwtAuthGuard.
| Method | Path | Description |
|---|---|---|
POST | /notifications | Create a notification (internal/system callers). Body = CreateNotificationDto |
GET | /notifications?limit=&offset= | List the caller's notifications, newest first. limit default 50, clamped 1โ200; offset default 0 |
PATCH | /notifications/:id/read | Mark one notification read (ownership-checked; idempotent โ re-marking returns the row unchanged) |
CreateNotificationDto (dto/create-notification.dto.ts): userId (uuid, recipient), type (non-empty string), message (non-empty string), title?, meta? (object).
POST /notificationsis currentlyJwtAuthGuard-only. APlatformRolesGuardtier for this system-to-user endpoint is a tracked #170 follow-up (see theTODOin the controller) โ until then, treat the create endpoint as internal-caller-only.
There is no unread-count endpoint โ the frontend derives the badge count from the GET /notifications list (readAt === null). See api/reference.md for the canonical endpoint table.
Real-time surface (Socket.io)โ
Source: api/src/notifications/notifications.gateway.ts. Namespace /notifications.
Connection / auth. A client supplies its JWT via the Socket.io auth object, an ?token= query param, or an Authorization: Bearer header. The gateway verifies it with JWT_SECRET; a missing/invalid token disconnects the socket. On connect the socket joins rooms derived from the verified token:
| Room | Who joins | Carries |
|---|---|---|
user:<userId> | every authenticated socket | notification:new (AppNotification delivery) |
org:<orgId> | non-client roles, one per JWT org membership | org-wide activity (agent_message_sent, user_message_received) |
experts | expert + superadmin | Expert-queue events |
conversation:<id> | joined on demand via join_conversation (ACL-checked) | per-thread live updates |
Widget visitors (platformRole = "client") are deliberately kept off org:* rooms โ those carry full message payloads for every thread in the org.
AppNotification event. When NotificationsService.create() persists a row it calls gateway.emitToUser(recipientUserId, "notification:new", dto). The single event the in-app notification surface emits is:
| Event | Direction | Room | Payload |
|---|---|---|---|
notification:new | server โ client | user:<recipientUserId> | the same NotificationDto returned by REST |
(The gateway also emits Expert-queue and conversation events โ expert_queue_item_added, queue_item_resolved, queue_item_status_updated, queue_item_updated (non-terminal transition, #388), conversation_status_changed, agent_message_sent, user_message_received, workspace_message_added, etc. โ and accepts join_conversation / leave_conversation from clients. Those belong to the queue/conversation surfaces, not the AppNotification pipeline; the canonical list is in api/reference.md.)
Multi-pod fan-out. The gateway uses a Redis-backed Socket.io adapter (redis-io.adapter.ts). With REDIS_URL set, a notification:new emitted on pod A reaches the recipient's socket on pod B via Redis pub/sub โ mandatory under active-active (root CLAUDE.md). Without REDIS_URL it logs a warning and falls back to the in-memory adapter (single-pod/dev only); in a multi-pod deploy that silently drops cross-pod delivery, so the missing-REDIS_URL warning is an alarm, not noise.
Delivery semantics: retry, dedup, idempotencyโ
The pipeline is intentionally thin โ there is no retry queue and no global dedup inside the notification module. The contract is best-effort real-time over a durable store:
- Persist-then-emit, no rollback.
create()saves the row, then emits. If the Socket.io emit fails the row is already durable; the recipient still sees it on nextGET /notifications. The DB write is the source of truth; the push is a latency optimization. - No replay on reconnect. A socket that was offline does not get missed
notification:newevents replayed. The frontend compensates by fetchingGET /notificationson mount/reconnect and reconciling. - Frontend dedup by
id.NotificationContextfilters incomingnotification:newagainst the REST-loaded list byitem.id, so a race between the push and an in-flightgetNotifications()can't double-render. - Idempotency is the caller's job. The module does not dedup by
(recipient, type)โ repeatedcreate()calls write repeated rows. Callers that can fire more than once (BullMQ retries, webhook re-delivery) own their own idempotency. Example:WorkspaceProvisioningProcessorre-runs are idempotent at the job level but can produce a duplicate notification for a persistently failing job โ an accepted trade-off, since the row is just a heads-up to the AM.
Producers โ who creates notificationsโ
Notifications are created by internal services injecting NotificationsService, not by clients hitting POST /notifications:
| Producer | Source | event_type | When |
|---|---|---|---|
BillingPaymentFailedNotifier | api/src/billing/infrastructure/billing-payment-failed.notifier.ts | billing.payment_failed | Lago payment_failed webhook โ notifies the assigned AM + billing-responsible org members (best-effort per recipient; one failure doesn't block the rest) |
WorkspaceProvisioningProcessor | api/src/workspace-lifecycle/workspace-provisioning.processor.ts | workspace_provisioning_failed | BullMQ provisioning job fails after retries โ notifies the assigned AM |
Adding a producer: import NotificationsModule, inject NotificationsService, and call create({ userId, type, message, title?, meta? }). Put anything the UI needs to deep-link (e.g. conversationId, orgSlug, invoiceId) into meta โ the inbox reads meta.conversationId to link a row to its thread.
Frontendโ
- Context / transport:
frontend/src/contexts/NotificationContext.tsxโ opens the/notificationsSocket.io connection, listens fornotification:new, callsgetNotifications()/markNotificationRead()(frontend/src/lib/api.ts), dedupes byid, and derives the unread count. NotificationBell(components/shared/NotificationBell.tsx, #915) โ bell icon + unread pill + dropdown. Deprecated on the client shell as of 2026-06-05 (#1866); still used on Expert (/workspace/*) and Ops (/ops/*) top-navs. See COMPONENT_INVENTORY.md.- Client inbox:
frontend/src/components/client/notifications/InboxPage.tsxโ the dedicated/client/inboxroute (#1866) that replaced the bell on/client/*. Tabs the feed into All / Mentions / Assignments / System viacategorizeNotificationType().
Observability & triageโ
The module's observability surface is structured logs (no dedicated metrics yet):
- Gateway logs connection/auth outcomes (
Client connected โฆ,Auth failed for client โฆ), room-join denials, and every emit atdebug. RedisIoAdapterlogs adapter init / pub-sub errors and theREDIS_URL not setfallback warning.
Runbook for triaging stuck or missing notifications (no real-time push, badge not clearing, cross-pod gaps): runbooks/notification-failures.md.
Relatedโ
- api/reference.md โ Notifications โ canonical REST + Socket.io event reference
- design/COMPONENT_INVENTORY.md โ NotificationBell
- architecture/README.md
- runbooks/notification-failures.md