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. |
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 }). Customize the resolver, or turn it off:
// Default: controller 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 }) },
});
// Auth guard + a custom base path (avoids colliding with a `/notifications` page
// route under a shared global prefix):
DatabaseChannelModule.forRoot({
controller: {
resolveRef: (req) => ({ type: 'User', id: req.user.id }),
guards: [AuthGuard],
path: 'notifications-inbox',
},
});
// Off — mount it yourself, or expose your own endpoints:
DatabaseChannelModule.forRoot({ controller: false });Mounting your own controller does not turn this one off. controller 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 guards rather than yours. That duplicate will shadow a page route your
app serves at /notifications. Pass controller: false whenever you mount the controller
yourself; the library logs a warning at bootstrap when it detects both.
Mounting it yourself
When you pass controller: 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({
// `controller: false` — without it you get this controller AND the auto-mounted one.
imports: [DatabaseChannelModule.forFeature({ controller: 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. Still
pass your auth guard via the guards option (on either controller: { guards } or
createNotificationsController({ guards })) — ownership scoping is only as good as the identity
resolveRef reads off the request.
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.