@nathapp/nestjs-notify
v1.1.0
Published
Notification management with templates, preferences and delivery strategies for NestJS
Downloads
172
Readme
@nathapp/nestjs-notify
Notification management for NestJS: templates, per-user/per-tenant preferences,
delivery channels, and delivery tracking. It orchestrates when and how a
notification is sent; the actual transport (email/SMS/push) is provided by a
delivery channel — either one you register, or ConsoleDeliveryChannel for
development.
- Templates — register and render content with a pluggable
ITemplateEngine(an HTML-escapingSimpleTemplateEngineships by default). - Preferences — per
(userId, tenantId, channel)opt-in/out, with ametadatabag for richer state (e.g. consent). - Delivery channels — pluggable adapters keyed by a channel string. The package ships no transport of its own except a dev-only console logger.
- Delivery tracking —
IDeliveryTrackerrecords normalized delivery events you feed it from provider webhooks.
This package is ORM-agnostic: the core defines repository interfaces, and the
@nathapp/nestjs-notify-prisma package supplies Prisma implementations.
Built-in channels
NotificationChannel enumerates four channels. Note the lowercase string
values:
| Member | Value | Delivery adapter shipped? |
| --- | --- | --- |
| NotificationChannel.EMAIL | 'email' | No transport in this package |
| NotificationChannel.PUSH | 'push' | No transport in this package |
| NotificationChannel.SMS | 'sms' | No transport in this package |
| NotificationChannel.IN_APP | 'in_app' | No — in-app delivery is entirely the consumer's concern |
IN_APP ships no delivery adapter. If you send on IN_APP you must register
your own channel for it (or accept the CHANNEL_NOT_REGISTERED error).
ConsoleDeliveryChannel is a development/testing adapter that logs the channel
name and template code to stdout instead of sending. It reads no recipient or
rendered content. It defaults to EMAIL (new ConsoleDeliveryChannel()
registers as 'email'), but the constructor accepts any channel name, e.g.
new ConsoleDeliveryChannel('sms'). Do not register it in production.
Registering a custom channel
A channel is any @Injectable() class implementing IDeliveryChannel — a
channel name plus a send() method. Register it under deliveryChannels,
passing an IDeliveryChannelProvider ({ channel, provider }) so Nest can
instantiate it with full DI:
import {
IDeliveryChannel,
NotificationChannelLike,
NotifyModule,
SendNotificationPayload,
} from '@nathapp/nestjs-notify';
import { Injectable, Module } from '@nestjs/common';
@Injectable()
export class WechatMpDeliveryChannel implements IDeliveryChannel {
readonly channel: NotificationChannelLike = 'wechat_mp';
async send(
payload: SendNotificationPayload,
renderedContent: string,
renderedSubject?: string,
): Promise<void> {
// call the WeChat subscribeMessage API
}
}
@Module({
imports: [
NotifyModule.register({
deliveryChannels: [{ channel: 'wechat_mp', provider: WechatMpDeliveryChannel }],
}),
],
})
export class AppModule {}channel on the instance and channel in the provider entry must be the same
string; the module uses the provider entry to build the DI injection token and
the instance's own channel as the registry key.
Case sensitivity
Channel keys are case- and separator-sensitive, end to end. The DI token key
(getDeliveryChannelToken) and the DeliveryChannelRegistry Map key are both
the raw channel string, byte for byte. Therefore:
'EMAIL'and'email'are two different channels with two different DI tokens and two different registry entries.'wechat-mp'and'wechat_mp'are two different channels (as they always were — the separator is never normalized).
History: getDeliveryChannelToken previously upper-cased the channel when
building its cache key, which folded case variants onto a single token
('EMAIL' and 'email' both became DELIVERY_CHANNEL_EMAIL). That case folding
has been fixed to raw-string keying. Separator variants were never collapsed.
Prefer the NotificationChannel enum members or a single lower-case convention
in your own code.
The typo hazard
Because NotificationChannelLike is NotificationChannel | (string & Record<never, never>), a
mistyped channel name is no longer a compile error — the widened type
deliberately accepts arbitrary strings. Two failure modes follow:
- At send time: sending on an unregistered channel throws
NotifyException(NotifyExceptionCode.CHANNEL_NOT_REGISTERED)→ HTTP 400. - Worse, silently:
DefaultPreferenceService.updatePreferencedelegates toIPreferenceRepository.upsertByUserAndChannel, whose Prisma implementation usesupdate: { enabled }. Saving a preference under a wrong channel does not error — it upserts a row for that (wrong) channel key, and on a subsequent call with the same wrong key overwrites the existing row'senabledrather than surfacing the mistake.
If your code accepts user-supplied channel names, validate them against the registry before persisting or sending:
import { BadRequestException, Inject } from '@nestjs/common';
import { DELIVERY_CHANNEL_REGISTRY, DeliveryChannelRegistry } from '@nathapp/nestjs-notify';
constructor(
@Inject(DELIVERY_CHANNEL_REGISTRY) private readonly registry: DeliveryChannelRegistry,
) {}
setPreference(userId: string, tenantId: string, channel: string, enabled: boolean) {
if (!this.registry.has(channel)) {
throw new BadRequestException(`Unknown notification channel: ${channel}`);
}
return this.preferenceService.updatePreference(userId, tenantId, channel, enabled);
}Preferences
A preference is keyed by @@unique([userId, tenantId, channel]), so there is at
most one row per user, tenant, and channel. Each row carries an enabled: boolean
and an optional metadata: Record<string, any>.
metadata is where consent state belongs. A boolean enabled alone cannot
model consent-gated transports: WeChat mini-program subscribe messages, for
example, require per-message user consent that can be pending, granted, or
revoked, and often carries a consent timestamp or scope token. Store that in
metadata alongside enabled.
Note: the shipped defaults ignore metadata on write —
DefaultPreferenceService.updatePreference hardcodes metadata: {}, and the
Prisma repository's upsertByUserAndChannel updates only { enabled }. To
persist consent state in metadata, override the default preference service
(and/or repository) with an implementation that accepts and writes it.
No controllers ship
This package exposes services, module registration, and interfaces only. It ships no controllers and no DTOs. Validating a channel name, authorizing the caller, shaping request bodies, and deciding which channels a user may configure are all the consumer's responsibility.
Queue processing
When enabling the notification processor, import the configured queue module inside NotifyModule so its QueueService can resolve in the notification module. A sibling import alone does not expose a non-global queue provider.
const queue = QueueModule.register({
provider: QueueProviderType.BULLMQ,
options: { connection: { host: 'localhost', port: 6379 } },
});
NotifyModule.register({
enableProcessor: true,
imports: [queue],
});Import QueueModule and QueueProviderType from @nathapp/nestjs-queue.
For asynchronous registration, pass the same module in
NotifyModule.registerAsync({ imports: [queue], ... }).
