nestjs-notifications
Recipes

Channel preferences

Let users mute channels they don't want. Register PreferencesModule, mute/unmute per user (and per tenant), and muted channels are auto-skipped because the package binds the core PreferenceGate.

Users want control: email me invoices, but not in Slack. The preferences package adds an opt-out layer — a channel is allowed unless a user has explicitly muted it — and wires it straight into delivery, so a muted channel is skipped automatically. No shouldSend boilerplate on every notification.

Register the module

app.module.ts
import { PreferencesModule } from '@dudousxd/nestjs-notifications-preferences';

@Module({
  imports: [
    PreferencesModule.forRoot(), // in-memory store
    // PreferencesModule.forRoot({ store: PrismaPrefStore }), // your own store
  ],
})
export class AppModule {}

PreferencesModule.forRoot() defaults to an in-memory store; pass a store class implementing PreferenceStore to persist preferences in your database. It registers globally by default.

Registering the module is all the wiring there is. It binds the core NOTIFICATION_PREFERENCE_GATE token to a store-backed gate, which the channel runner consults before every delivery.

Mute and unmute

Inject NotificationPreferences and toggle channels for a user:

preferences.service.ts
import { Injectable } from '@nestjs/common';
import { NotificationPreferences } from '@dudousxd/nestjs-notifications-preferences';

@Injectable()
export class PreferencesService {
  constructor(private readonly prefs: NotificationPreferences) {}

  async muteEmail(user: User) {
    await this.prefs.mute(user, 'mail');
  }

  async unmuteEmail(user: User) {
    await this.prefs.unmute(user, 'mail');
  }

  async emailMuted(user: User) {
    return this.prefs.isMuted(user, 'mail'); // boolean
  }
}
MethodEffect
mute(target, channel)Mute a channel for the target. Idempotent.
unmute(target, channel)Un-mute a channel. Idempotent.
isMuted(target, channel)Whether the channel is muted for the target.
muted(target)List the channels currently muted for the target.

The target is any notifiable (its reference is derived via the core notifiableRef — so it needs a @NotifiableId()/toNotifiableRef()), or a plain { type, id } reference.

Muted channels are auto-skipped

You don't check preferences when sending — the gate does it for you. A send to a user who muted mail simply skips that channel; the result records it as skipped, and the channel never runs:

await this.prefs.mute(user, 'mail');

const [result] = await notifications.send(user, new InvoicePaid(invoice));
result.results;
// [
//   { channel: 'mail', status: 'skipped' },
//   { channel: 'database', status: 'sent' },
// ]

This is the same skipped status produced by a notification's own shouldSend — the preference gate just applies it app-wide from stored user choices.

Per-tenant preferences

In a multi-tenant app a user's choices differ per workspace. Scope preferences with forTenant(id) — a mute is then keyed by user and tenant:

await this.prefs.forTenant('acme').mute(user, 'mail');   // muted only in Acme
await this.prefs.forTenant('globex').isMuted(user, 'mail'); // false — different workspace

The gate is tenant-aware to match: a send scoped to acme honours the acme-scoped mutes, while a send to another tenant (or an unscoped one) is unaffected.

Mutes are opt-out: a channel with no stored preference is always allowed. A target with no stable reference (e.g. an anonymous on-demand route) can't be muted, so it's allowed by default.

Bring your own store

The default InMemoryPreferenceStore is fine for tests and single-process apps. For production, implement PreferenceStore against your database and pass it to forRoot({ store }):

import type { PreferenceKey, PreferenceScope, PreferenceStore } from '@dudousxd/nestjs-notifications-preferences';

@Injectable()
export class PrismaPrefStore implements PreferenceStore {
  isMuted(key: PreferenceKey): Promise<boolean> { /* … */ }
  mute(key: PreferenceKey): Promise<void> { /* … */ }
  unmute(key: PreferenceKey): Promise<void> { /* … */ }
  mutedChannels(scope: PreferenceScope): Promise<string[]> { /* … */ }
}

Each PreferenceKey carries the optional tenant, the notifiable's type/id, and the channel — everything you need to store and query a row.

Beyond opt-out: the preference center

Muting is a single on/off per channel. Real apps need a matrix: a user wants billing emails but not billing push, product updates only weekly, and security alerts they can't turn off at all. That's the preference center — a grid of categories × channels, plus a per-category digest frequency.

A notification declares its category; everything else falls out of the user's matrix.

invoice-paid.notification.ts
@Notification()
export class InvoicePaid {
  readonly category = 'billing'; // ← the row in the matrix

  via() {
    return [Mail, Database];
  }
  // ...
}

Register the center with your category definitions instead of forRoot:

app.module.ts
import { PreferencesModule } from '@dudousxd/nestjs-notifications-preferences';

PreferencesModule.forCenter({
  categories: [
    { key: 'billing', label: 'Billing', allowDigest: true },
    { key: 'product', label: 'Product updates', allowDigest: true, defaultChannels: ['mail'] },
    { key: 'security', label: 'Security alerts', mandatory: true }, // can't be turned off
  ],
});

forCenter binds a category-aware gate to the same NOTIFICATION_PREFERENCE_GATE token, so the ChannelRunner enforces the matrix automatically:

  • a disabled (category × channel) toggle → that channel is skipped;
  • a category set to daily/weekly digest → instant delivery is suppressed (a scheduled digest job, which you own, would batch and send those later);
  • a mandatory category (e.g. security) is always delivered, ignoring the matrix.

A UI in one controller

createPreferenceCenterController exposes the matrix as REST, ready for a settings screen:

import { createPreferenceCenterController } from '@dudousxd/nestjs-notifications-preferences';

const PreferenceCenterController = createPreferenceCenterController({
  resolveRef: (req) => ({ type: 'User', id: req.user.id }),
});
RoutePurpose
GET /preferences/categoriesThe configured category definitions
GET /preferencesThe current user's matrix
PUT /preferences/:category/channels/:channel{ enabled } — toggle one cell
PUT /preferences/:category/digest{ digest } — set a category's frequency

Every route except GET /preferences/categories is scoped to the notifiable resolveRef returns. Decide who reaches the controller with decorators (or by mounting it in your own module) — see Authorization of the mounted routes:

createPreferenceCenterController({
  resolveRef: (req) => ({ type: 'User', id: req.user.id }),
  decorators: [UseGuards(AuthenticationGuard), Can(PreferencesPolicy, 'manage')],
});

The center is additive — the simple forRoot opt-out API above still works unchanged. Pick forRoot for a single mute switch, forCenter when users need the full grid.

If an app imports both forRoot() and forCenter(), the preference center's gate decides every delivery, whichever module is imported first. The opt-out mutes set through NotificationPreferences are then not consulted by the gate, so store the user's choices in the center's matrix.

On this page