Aviary
Recipes

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:

inbox.service.ts
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.

MethodReturnsDescription
all(target, { types? })StoredNotification[]Every notification for the target, newest first.
unread(target, { types? })StoredNotification[]Unread notifications for the target.
unreadCount(target, { types? })numberHow 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)voidMark one notification read, by its id.
markAllAsRead(target)voidMark every notification for the target read.
delete(id)voidRemove 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:

inbox.module.ts
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:

RouteAction
GET /notificationsPaginated list (?page & ?perPage, ?type= filters).
GET /notifications/unreadUnread notifications (?type= filters).
GET /notifications/unread/count{ count } of unread (?type= filters).
POST /notifications/:id/readMark one read (only the caller's own).
POST /notifications/read-allMark all read for the current user.
DELETE /notifications/:idDelete 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:

MethodRole
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.

On this page