NestboltNestbolt

@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
  }
}