In-app notifications
Build an in-app feed from persisted notifications. Inject NotificationsQueryService to list, count, and mark read — or mount the optional REST controller in one call.
Once the database channel persists notifications, you render them in-app —
an unread badge, a dropdown, a "mark all as read" button. NotificationsQueryService is the read
side: it wraps the store with the methods you actually reach for. Mount the bundled REST controller
and you have an inbox API with zero glue code.
Inject the query service
NotificationsQueryService is provided by DatabaseChannelModule (and its forFeature()), so it's
injectable anywhere the module is in scope:
import { Injectable } from '@nestjs/common';
import { NotificationsQueryService } from '@dudousxd/nestjs-notifications-database';
@Injectable()
export class InboxService {
constructor(private readonly notifications: NotificationsQueryService) {}
async feed(user: User) {
const items = await this.notifications.all(user);
const badge = await this.notifications.unreadCount(user);
return { items, badge };
}
}Every method that reads "whose notifications" accepts a target: either a notifiable (anything with
toNotifiableRef()) or a plain { type, id } reference.
| Method | Returns | Description |
|---|---|---|
all(target, { types? }) | StoredNotification[] | Every notification for the target, newest first. |
unread(target, { types? }) | StoredNotification[] | Unread notifications for the target. |
unreadCount(target, { types? }) | number | How many are unread. A COUNT in the database when the store implements countUnread (every bundled store does). |
paginate(target, { page?, perPage?, types? }) | { items, meta: { page, perPage, total, lastPage } } | One page over all (defaults: page 1, 20 per page). |
markAsRead(id) | void | Mark one notification read, by its id. |
markAllAsRead(target) | void | Mark every notification for the target read. |
delete(id) | void | Remove one notification, by its id. |
types is an optional string[] that restricts results to matching type values (the same value
notificationName() persisted — a
notificationType() override, @Notification({ name }), or the class name). Omit it, or pass an
empty array, and every type matches — purely additive, existing calls are unaffected:
// Only the two file-export notification types for this user's feed.
await this.notifications.unread(user, { types: ['FILE_EXPORT_RUNNING', 'FILE_EXPORT_DONE'] });markAsRead(id) and delete(id) take a notification id (a string), not a target — they act on
a single row. markAllAsRead(target) takes the target, since it spans every row for that notifiable.
Pass a ref directly when you only have an id at hand — handy in a controller where the user comes off the request:
await this.notifications.unreadCount({ type: 'User', id: req.user.id });The REST controller
DatabaseChannelModule.forRoot() auto-mounts the inbox controller by default — you get
GET/POST/DELETE /notifications out of the box, with the current notifiable resolved from
req.user ({ type: 'User', id: req.user.id }, 401 when there is no req.user.id). Customize
the resolver, or turn it off:
// Default: routes on, resolveRef reads req.user.
DatabaseChannelModule.forRoot();
// Custom resolver (e.g. a different id field or notifiable type):
DatabaseChannelModule.forRoot({
controller: { resolveRef: (req) => ({ type: 'User', id: req.auth.sub }) },
});
// Authentication + a custom base path (avoids colliding with a `/notifications` page
// route under a shared global prefix):
DatabaseChannelModule.forRoot({
controller: { path: 'notifications-inbox' },
decorators: [UseGuards(AuthenticationGuard)],
});
// Off — mount it yourself, or expose your own endpoints:
DatabaseChannelModule.forRoot({ routes: false });Mounting your own controller does not turn this one off. routes defaults to true, so
forRoot() auto-mounts the inbox whether or not you also call createNotificationsController()
— you end up with two, and the auto-mounted one sits on the default notifications path with the
module's resolveRef and decorators rather than yours. That duplicate will shadow a page route
your app serves at /notifications. Pass routes: false whenever you mount the controller
yourself; the library logs a warning at bootstrap when it detects both.
Mounting it yourself
When you pass routes: false (or want it in a specific module), createNotificationsController()
builds the same @Controller('notifications'). Tell it how to resolve the current notifiable from the
request, then add the returned class to a module's controllers:
import { Module } from '@nestjs/common';
import {
DatabaseChannelModule,
createNotificationsController,
} from '@dudousxd/nestjs-notifications-database';
const NotificationsController = createNotificationsController({
resolveRef: (req) => ({ type: 'User', id: req.user.id }),
});
@Module({
// `routes: false` — without it you get this controller AND the auto-mounted one.
imports: [DatabaseChannelModule.forFeature({ routes: false })],
controllers: [NotificationsController],
})
export class InboxModule {}resolveRef receives the request and returns a { type, id } ref (it may be async) — wire it to
whatever your auth layer puts on req.user. The controller exposes:
| Route | Action |
|---|---|
GET /notifications | Paginated list (?page & ?perPage, ?type= filters). |
GET /notifications/unread | Unread notifications (?type= filters). |
GET /notifications/unread/count | { count } of unread (?type= filters). |
POST /notifications/:id/read | Mark one read (only the caller's own). |
POST /notifications/read-all | Mark all read for the current user. |
DELETE /notifications/:id | Delete one (only the caller's own). |
?type= accepts a comma-separated list of types (e.g.
?type=FILE_EXPORT_RUNNING,FILE_EXPORT_DONE); entries are trimmed and blank entries dropped.
Absent, or empty after parsing, means no filter — every type matches.
The controller relies on resolveRef to scope every route to the current user. The per-id routes
(:id/read, DELETE :id) are ownership-checked: a notification belonging to someone else
responds 404 rather than 403, so the endpoint can't be used to probe which ids exist.
Ownership scoping is only as good as the identity resolveRef reads off the request, so put
authentication in front of it — see below.
Authorization
Decide who reaches the inbox with @nestjs/authentication / @nestjs/authorization: pass
decorators (e.g. [UseGuards(AuthenticationGuard), Can(InboxPolicy, 'read')]) to
DatabaseChannelModule.forRoot()/forFeature() or createNotificationsController(), or mount the
controller yourself with routes: false. See Authorization of the mounted routes.
Ownership scoping and custom stores
The ownership check runs in the store. Every bundled adapter (in-memory, TypeORM, MikroORM, Prisma)
implements it, pushing the notifiable into the WHERE clause so there's no read-then-write window.
If you wrote your own NotificationStore, implement either the scoped mutations or findById:
| Method | Role |
|---|---|
deleteOwned(id, owner) | Delete only when the row belongs to owner. Resolves true on a hit. Preferred. |
markAsReadOwned(id, owner) | Mark read only when the row belongs to owner. Resolves true on a hit, including an already-read row. Preferred. |
findById(id) | Fallback — the query service reads the row, compares the notifiable (and tenant), then mutates. |
owner is a { notifiableType, notifiableId, tenantId? } ref; an absent tenantId matches any
tenant. All three are optional, so existing stores keep compiling — but a store implementing none of
them cannot verify ownership, and the library logs a warning saying so rather than failing quietly.
Calling notifications.delete(id) or markAsRead(id) without a target stays unscoped, so
programmatic callers acting outside a request are unaffected.
Real-time & in-app
nestjs-notifications is also your real-time delivery layer. Persist with the database channel, push live with SSE or WebSocket, and consume in React — the whole in-app notification loop, and how to pick SSE vs WebSocket.
React inbox widget
Drop a notification inbox into a React app with one component. The @dudousxd/nestjs-notifications-react package ships <Inbox/>, a NotificationsProvider, and the useNotifications / useUnreadCount hooks — all consuming the read API and SSE stream you already expose.