Skip to main content

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.

ColumnTypeNotes
iduuid PKgenerated on insert
recipient_user_idtextthe user who receives the notification; indexed
event_typevarchar(100)the notification type (e.g. billing.payment_failed) โ€” exposed as type on the wire
titletext, nullableoptional headline
bodytext, nullablemessage body โ€” exposed as message on the wire
metajsonb, nullablemetadata bag (org id, slug, conversation id, invoice id, โ€ฆ)
read_attimestamptz, nullablenull = unread; set once on first mark-read
created_attimestamptz@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 filters recipient_user_id = :userId. The controller resolves userId from the JWT (req.user.id ?? userId ?? sub) and throws UnauthorizedException if it is missing โ€” otherwise findAllForUser(undefined) would degrade to "no filter" and leak across users (Greptile P1, #915).
  • markRead(id, requestingUserId) re-checks ownership and throws ForbiddenException when recipient_user_id !== requestingUserId.
  • The Socket.io path is room-scoped: a notification is only emitted to user:<recipientUserId>, and a socket only joins its own user:<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.

MethodPathDescription
POST/notificationsCreate 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/readMark 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 /notifications is currently JwtAuthGuard-only. A PlatformRolesGuard tier for this system-to-user endpoint is a tracked #170 follow-up (see the TODO in 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:

RoomWho joinsCarries
user:<userId>every authenticated socketnotification:new (AppNotification delivery)
org:<orgId>non-client roles, one per JWT org membershiporg-wide activity (agent_message_sent, user_message_received)
expertsexpert + superadminExpert-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:

EventDirectionRoomPayload
notification:newserver โ†’ clientuser:<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 next GET /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:new events replayed. The frontend compensates by fetching GET /notifications on mount/reconnect and reconciling.
  • Frontend dedup by id. NotificationContext filters incoming notification:new against the REST-loaded list by item.id, so a race between the push and an in-flight getNotifications() can't double-render.
  • Idempotency is the caller's job. The module does not dedup by (recipient, type) โ€” repeated create() calls write repeated rows. Callers that can fire more than once (BullMQ retries, webhook re-delivery) own their own idempotency. Example: WorkspaceProvisioningProcessor re-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:

ProducerSourceevent_typeWhen
BillingPaymentFailedNotifierapi/src/billing/infrastructure/billing-payment-failed.notifier.tsbilling.payment_failedLago payment_failed webhook โ†’ notifies the assigned AM + billing-responsible org members (best-effort per recipient; one failure doesn't block the rest)
WorkspaceProvisioningProcessorapi/src/workspace-lifecycle/workspace-provisioning.processor.tsworkspace_provisioning_failedBullMQ 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 /notifications Socket.io connection, listens for notification:new, calls getNotifications() / markNotificationRead() (frontend/src/lib/api.ts), dedupes by id, 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/inbox route (#1866) that replaced the bell on /client/*. Tabs the feed into All / Mentions / Assignments / System via categorizeNotificationType().

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 at debug.
  • RedisIoAdapter logs adapter init / pub-sub errors and the REDIS_URL not set fallback warning.

Runbook for triaging stuck or missing notifications (no real-time push, badge not clearing, cross-pod gaps): runbooks/notification-failures.md.