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
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:
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
}
}| Method | Effect |
|---|---|
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 workspaceThe 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.
@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:
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/weeklydigest → instant delivery is suppressed (a scheduled digest job, which you own, would batch and send those later); - a
mandatorycategory (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 }),
});| Route | Purpose |
|---|---|
GET /preferences/categories | The configured category definitions |
GET /preferences | The 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.
Typed client with codegen
Generate a fully typed HTTP client for the inbox API from your NestJS controllers using nestjs-codegen — no hand-written fetch calls, no drift between server and client.
Authorization of the mounted routes
Decide who reaches the inbox, SSE stream, preference center and delivery callbacks with NestJS's own @nestjs/authentication and @nestjs/authorization — by passing decorators or by mounting the controller yourself.