@nestbolt/notifications
Custom Channels
Implement custom notification channels for Slack, SMS, webhooks, or any delivery transport by implementing the NotificationChannel interface.
The built-in database and mail channels cover common use cases, but you can create custom channels to deliver notifications through any transport: Slack, Twilio SMS, Firebase push notifications, webhooks, or anything else.
The NotificationChannel Interface
Every channel must implement the NotificationChannel interface:
import type { Notification } from "@nestbolt/notifications";
import type { NotifiableEntity } from "@nestbolt/notifications";
interface NotificationChannel {
send(notifiable: NotifiableEntity, notification: Notification): Promise<void>;
}The send method receives:
notifiable-- the entity receiving the notification. Use this to determine routing information (e.g., a Slack user ID, phone number, or webhook URL).notification-- the notification instance. Call a custom method on it (e.g.,toSlack()) to get the channel-specific payload.
Creating a Custom Channel
Step 1: Implement the Channel
Create an injectable class that implements NotificationChannel:
// channels/slack.channel.ts
import { Injectable } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { firstValueFrom } from "rxjs";
import type {
NotificationChannel,
NotifiableEntity,
Notification,
} from "@nestbolt/notifications";
@Injectable()
export class SlackChannel implements NotificationChannel {
constructor(private readonly http: HttpService) {}
async send(notifiable: NotifiableEntity, notification: Notification): Promise<void> {
const data = (notification as any).toSlack();
// Resolve the Slack webhook URL from the notifiable entity
const webhookUrl = notifiable.routeNotificationFor?.("slack");
if (!webhookUrl) {
throw new Error(
`Cannot determine Slack webhook URL for ${notifiable.getNotifiableType()}:${notifiable.getNotifiableId()}`,
);
}
await firstValueFrom(
this.http.post(webhookUrl, {
text: data.text,
channel: data.channel,
blocks: data.blocks,
}),
);
}
}Step 2: Register the Channel
Register your custom channel in the module configuration. With forRoot(), pass it in the channels.custom map:
import { SlackChannel } from "./channels/slack.channel";
NotificationModule.forRoot({
channels: {
database: true,
custom: {
slack: SlackChannel,
},
},
});With forRootAsync(), use the customChannels option at the top level:
import { SlackChannel } from "./channels/slack.channel";
NotificationModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
channels: {
database: true,
},
}),
customChannels: {
slack: SlackChannel,
},
});The key ("slack") is the channel name used in via().
Step 3: Create a Notification
Add a toSlack() method to your notification class:
import { Notification } from "@nestbolt/notifications";
export class DeploymentNotification extends Notification {
constructor(
private deployment: {
service: string;
version: string;
environment: string;
url: string;
},
) {
super();
}
via(): string[] {
return ["database", "slack"];
}
toDatabase() {
return {
message: `${this.deployment.service} v${this.deployment.version} deployed to ${this.deployment.environment}.`,
service: this.deployment.service,
version: this.deployment.version,
};
}
toSlack() {
return {
text: `Deployment: ${this.deployment.service} v${this.deployment.version} deployed to ${this.deployment.environment}`,
channel: "#deployments",
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*${this.deployment.service}* v${this.deployment.version} has been deployed to *${this.deployment.environment}*.`,
},
},
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "View Deployment" },
url: this.deployment.url,
},
],
},
],
};
}
}Step 4: Configure Routing on the Entity
Tell the entity how to route notifications for the custom channel:
@Entity("teams")
@Notifiable()
export class Team extends NotifiableMixin(BaseEntity) {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column()
name!: string;
@Column()
slackWebhookUrl!: string;
routeNotificationFor(channel: string): string | undefined {
if (channel === "slack") return this.slackWebhookUrl;
return undefined;
}
}More Examples
SMS Channel (Twilio)
// channels/sms.channel.ts
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type {
NotificationChannel,
NotifiableEntity,
Notification,
} from "@nestbolt/notifications";
@Injectable()
export class SmsChannel implements NotificationChannel {
private client: any;
private fromNumber: string;
constructor(private readonly config: ConfigService) {
const twilio = require("twilio");
this.client = twilio(
config.get("TWILIO_ACCOUNT_SID"),
config.get("TWILIO_AUTH_TOKEN"),
);
this.fromNumber = config.get("TWILIO_FROM_NUMBER");
}
async send(notifiable: NotifiableEntity, notification: Notification): Promise<void> {
const data = (notification as any).toSms();
const to = notifiable.routeNotificationFor?.("sms");
if (!to) {
throw new Error(
`Cannot determine phone number for ${notifiable.getNotifiableType()}:${notifiable.getNotifiableId()}`,
);
}
await this.client.messages.create({
body: data.body,
from: this.fromNumber,
to,
});
}
}Using it in a notification:
export class VerificationCodeNotification extends Notification {
constructor(private code: string) {
super();
}
via(): string[] {
return ["sms"];
}
toSms() {
return {
body: `Your verification code is: ${this.code}`,
};
}
}Entity routing:
@Entity("users")
@Notifiable()
export class User extends NotifiableMixin(BaseEntity) {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column()
phone!: string;
routeNotificationFor(channel: string): string | undefined {
if (channel === "sms") return this.phone;
if (channel === "mail") return this.email;
return undefined;
}
}Webhook Channel
// channels/webhook.channel.ts
import { Injectable } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { firstValueFrom } from "rxjs";
import type {
NotificationChannel,
NotifiableEntity,
Notification,
} from "@nestbolt/notifications";
@Injectable()
export class WebhookChannel implements NotificationChannel {
constructor(private readonly http: HttpService) {}
async send(notifiable: NotifiableEntity, notification: Notification): Promise<void> {
const data = (notification as any).toWebhook();
const url = notifiable.routeNotificationFor?.("webhook");
if (!url) {
throw new Error("No webhook URL configured for this notifiable entity.");
}
await firstValueFrom(
this.http.post(url, {
event: notification.notificationType(),
data,
timestamp: new Date().toISOString(),
notifiable: {
type: notifiable.getNotifiableType(),
id: notifiable.getNotifiableId(),
},
}),
);
}
}Firebase Push Notification Channel
// channels/push.channel.ts
import { Injectable } from "@nestjs/common";
import * as admin from "firebase-admin";
import type {
NotificationChannel,
NotifiableEntity,
Notification,
} from "@nestbolt/notifications";
@Injectable()
export class PushChannel implements NotificationChannel {
async send(notifiable: NotifiableEntity, notification: Notification): Promise<void> {
const data = (notification as any).toPush();
const token = notifiable.routeNotificationFor?.("push");
if (!token) {
throw new Error("No FCM token found for this notifiable entity.");
}
await admin.messaging().send({
token,
notification: {
title: data.title,
body: data.body,
},
data: data.payload,
});
}
}Registering Multiple Custom Channels
You can register any number of custom channels at once:
NotificationModule.forRoot({
channels: {
database: true,
mail: { /* ... */ },
custom: {
slack: SlackChannel,
sms: SmsChannel,
webhook: WebhookChannel,
push: PushChannel,
},
},
});Dependency Injection in Channels
Custom channels are regular NestJS providers. They support full dependency injection -- you can inject any service, config, or repository:
@Injectable()
export class SlackChannel implements NotificationChannel {
constructor(
private readonly http: HttpService,
private readonly config: ConfigService,
private readonly logger: Logger,
) {}
async send(notifiable: NotifiableEntity, notification: Notification): Promise<void> {
// All injected services are available here
}
}Make sure any modules required by your channel's dependencies are imported in the NotificationModule.forRootAsync() configuration:
NotificationModule.forRootAsync({
imports: [HttpModule, ConfigModule],
// ...
customChannels: {
slack: SlackChannel,
},
});Error Handling
If a custom channel's send() method throws an error, the NotificationService catches it, logs it, emits a notification.failed event (if the event emitter is available), and then re-throws the error. This means any unhandled error in your channel will propagate to the caller.
To handle errors gracefully within your channel, wrap the logic in a try/catch:
async send(notifiable: NotifiableEntity, notification: Notification): Promise<void> {
try {
// ... send logic ...
} catch (error) {
// Log, retry, or handle the error
// If you do not re-throw, the notification is considered "sent"
throw error; // Re-throw to trigger the notification.failed event
}
}Database Channel
Store notifications in the database with full read/unread tracking, querying by entity, and bulk operations.
Notifiable Entities
Turn any TypeORM entity into a notification recipient using the @Notifiable() decorator and NotifiableMixin, with methods for sending and querying notifications.