This guide provides a comprehensive walkthrough for building a robust system capable of sending bulk SMS messages (broadcasts) using Node.js, the NestJS framework, and Twilio's Notify service. We'll cover everything from project setup and Twilio configuration to implementation, error handling, security, testing, and deployment best practices.
This implementation solves the challenge of efficiently sending the same message to a large list of recipients without hitting API rate limits or managing complex looping logic client-side. Twilio Notify is specifically designed for this purpose.
Technologies Used:
- Node.js: The JavaScript runtime environment.
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Chosen for its modular architecture, dependency injection, and built-in support for features like validation and configuration management.
- Twilio: A cloud communications platform providing APIs for SMS, voice, video, and more. We'll use the Twilio Node.js SDK and specifically the Notify service.
- TypeScript: Superset of JavaScript adding static typing, improving code quality and maintainability.
- (Optional) Prisma/TypeORM: ORMs for database interaction (demonstrated conceptually).
- (Optional) Docker: For containerization and simplified deployment.
System Architecture:
+-------------+ +---------------------+ +-----------------+ +----------------+
| Client | -----> | NestJS API Gateway | ---> | MessagingService| ---> | Twilio Notify |
| (e.g., Web, | | (Controller/Auth) | | (Core Logic) | | API |
| Mobile) | +---------------------+ +-----------------+ +----------------+
+-------------+ | |
| (Optional) | (Optional)
v v
+-------------+ +-----------------+
| Database | | Logging Svc |
| (Recipients,| | (e.g., Datadog, |
| Logs) | | Sentry) |
+-------------+ +-----------------+
Prerequisites:
- Node.js (LTS version recommended, e.g., v18 or v20) and npm/yarn.
- A Twilio account. If you don't have one, sign up for a free trial.
- Basic understanding of TypeScript, Node.js, and REST APIs.
- (Optional) Docker Desktop installed for containerized deployment.
- (Optional) A PostgreSQL database instance (or another DB compatible with Prisma/TypeORM).
Final Outcome:
By the end of this guide, you will have a NestJS application with an API endpoint that accepts a list of phone numbers and a message body, then uses Twilio Notify to efficiently dispatch these messages. The system will include validation, configuration management, basic error handling, logging, and guidance on testing and deployment. Note: For true production readiness, implementing robust authentication/authorization (Section 8) and potentially status callback webhooks (Section 10) is crucial but not fully coded in this guide.
1. Project Setup and Configuration
Let's initialize our NestJS project and set up the basic structure and configuration.
1. Install NestJS CLI:
If you haven't already, install the NestJS command-line interface globally.
npm install -g @nestjs/cli
2. Create New NestJS Project:
Generate a new project. Choose your preferred package manager (npm or yarn).
nest new nestjs-twilio-bulk-sms
cd nestjs-twilio-bulk-sms
3. Install Dependencies:
We need the Twilio Node.js SDK and NestJS configuration module.
npm install twilio @nestjs/config class-validator class-transformer
# or
yarn add twilio @nestjs/config class-validator class-transformer
twilio
: The official Twilio helper library.@nestjs/config
: For managing environment variables securely.class-validator
,class-transformer
: For validating incoming request data using DTOs.
4. Environment Variable Setup:
Create a .env
file in the project root for storing sensitive credentials and configuration. Never commit this file to version control! Add .env
to your .gitignore
file if it's not already there.
# .env
# Twilio Credentials
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token
TWILIO_NOTIFY_SERVICE_SID=ISxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Optional: If using a specific Messaging Service for Notify
# TWILIO_MESSAGING_SERVICE_SID=MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Application Settings
PORT=3000
- We'll get the Twilio values in the next section.
PORT
: The port your NestJS application will run on.
5. Configure NestJS ConfigModule:
Import and configure the ConfigModule
in your main application module (src/app.module.ts
) to load variables from the .env
file.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
// Import your messaging module later
// import { MessagingModule } from './messaging/messaging.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Make ConfigModule available globally
envFilePath: '.env', // Specify the env file path
}),
// MessagingModule, // Uncomment once created
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
6. Enable ValidationPipe:
Enable the global validation pipe in src/main.ts
to automatically validate incoming request bodies based on DTOs.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe, HttpException, HttpStatus } from '@nestjs/common'; // Import HttpException, HttpStatus
import { ConfigService } from '@nestjs/config'; // Import ConfigService
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS if needed (adjust origins for production)
app.enableCors();
// Use global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip properties not defined in DTO
transform: true, // Automatically transform payloads to DTO instances
forbidNonWhitelisted: true, // Throw error if extra properties are sent
}),
);
// Get port from ConfigService
const configService = app.get(ConfigService);
const port = configService.get<number>('PORT') || 3000;
await app.listen(port);
console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();
Our basic project structure and configuration are now set up.
2. Twilio Account and Service Setup
Before writing the code to send messages, we need to configure the necessary services within the Twilio console.
Goal: Obtain TWILIO_ACCOUNT_SID
, TWILIO_AUTH_TOKEN
, and TWILIO_NOTIFY_SERVICE_SID
.
Steps:
-
Login to Twilio: Go to
https://www.twilio.com/login
and log in to your account. -
Get Account SID and Auth Token:
- On your main account dashboard (
https://console.twilio.com/
), you'll find yourACCOUNT SID
andAUTH TOKEN
. - Important: Keep your
AUTH TOKEN
secure. Treat it like a password. - Copy these values into your
.env
file.
# .env TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Paste your Account SID TWILIO_AUTH_TOKEN=your_auth_token # Paste your Auth Token
- On your main account dashboard (
-
(Optional but Recommended) Create a Messaging Service: While Notify can sometimes use your pool of numbers, it's best practice to use a dedicated Messaging Service for features like sender ID management, content intelligence, and scalability.
- Navigate to Messaging -> Services (
https://console.twilio.com/us1/service/sms
). - Click
Create Messaging Service
. - Give it a friendly name (e.g.,
Bulk SMS Service
). - Select a use case (e.g.,
Notifications
,Marketing
). - Click
Create Messaging Service
. - On the service's configuration page, go to the
Sender Pool
section. - Add one or more Twilio phone numbers (or Alphanumeric Sender IDs, Short Codes if applicable and configured on your account) to this service. You need at least one sender in the pool.
- Note: If using a trial account, you can typically only send messages to verified phone numbers.
- Copy the Messaging Service SID (starts with
MG...
) if you created one. You might need it later, though often Notify can infer it. Add it to.env
if needed.
# .env (Optional) # TWILIO_MESSAGING_SERVICE_SID=MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
- Navigate to Messaging -> Services (
-
Create a Notify Service: This is the core service for bulk messaging.
- Navigate to Engage -> Notify -> Services (
https://console.twilio.com/us1/develop/notify/services
). You might need to search forNotify
or enable it. - Click
Create Notification Service
. - Give it a friendly name (e.g.,
App Broadcasts
). - Click
Create
. - Crucially: Configure the service to use SMS. Find the SMS channel configuration section.
- Select the Messaging Service SID you created in the previous step from the dropdown. This links Notify to your sender pool. If you skipped creating a Messaging Service, Twilio might attempt to use numbers directly from your account, but using a Messaging Service is preferred.
- Click
Save
. - Copy the Notify Service SID (starts with
IS...
) displayed on the service's page. - Paste this SID into your
.env
file.
# .env TWILIO_NOTIFY_SERVICE_SID=ISxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Paste your Notify Service SID
- Navigate to Engage -> Notify -> Services (
You now have all the necessary Twilio credentials and service SIDs configured in your .env
file.
3. NestJS Messaging Module
Let's create a dedicated module in NestJS to handle all messaging-related logic.
1. Generate the Module, Service, and Controller:
Use the NestJS CLI to generate the necessary files.
nest generate module messaging
nest generate service messaging --no-spec # --no-spec skips test file for brevity
nest generate controller messaging --no-spec
This creates:
src/messaging/messaging.module.ts
src/messaging/messaging.service.ts
src/messaging/messaging.controller.ts
2. Register the Module:
Import and add MessagingModule
to the imports
array in src/app.module.ts
.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MessagingModule } from './messaging/messaging.module'; // Import
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
MessagingModule, // Add MessagingModule here
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
3. Structure Overview:
MessagingController
: Will handle incoming HTTP requests related to messaging (e.g.,/messaging/bulk-sms
). It validates input and calls the service.MessagingService
: Contains the core business logic for interacting with the Twilio API. It formats data and makes the API calls.MessagingModule
: Bundles the controller and service together.
4. Implementing the Core Logic (MessagingService)
Now, let's implement the service that interacts with Twilio Notify.
// src/messaging/messaging.service.ts
import { Injectable, Logger, InternalServerErrorException, BadRequestException, HttpException, HttpStatus } from '@nestjs/common'; // Added HttpException, HttpStatus
import { ConfigService } from '@nestjs/config';
import Twilio from 'twilio'; // Use default import
@Injectable()
export class MessagingService {
private readonly logger = new Logger(MessagingService.name);
private twilioClient: Twilio.Twilio; // Use the correct type
private notifyServiceSid: string;
constructor(private configService: ConfigService) {
const accountSid = this.configService.get<string>('TWILIO_ACCOUNT_SID');
const authToken = this.configService.get<string>('TWILIO_AUTH_TOKEN');
this.notifyServiceSid = this.configService.get<string>('TWILIO_NOTIFY_SERVICE_SID');
if (!accountSid || !authToken || !this.notifyServiceSid) {
this.logger.error('Twilio credentials or Notify Service SID missing in environment variables.');
throw new InternalServerErrorException('Twilio configuration is incomplete.');
}
this.twilioClient = Twilio(accountSid, authToken); // Initialize Twilio client
this.logger.log('Twilio client initialized.');
}
/**
* Sends the same SMS message to multiple recipients using Twilio Notify.
* @param recipients - An array of phone numbers in E.164 format (e.g., '+15551234567').
* @param messageBody - The text content of the SMS.
* @returns The SID of the Notification resource created.
* @throws BadRequestException if the recipients array is empty.
* @throws InternalServerErrorException if the message sending fails for other reasons.
* @throws HttpException for specific errors like rate limiting.
*/
async sendBulkSms(recipients: string[], messageBody: string): Promise<string> {
if (!recipients || recipients.length === 0) {
this.logger.warn('Attempted to send bulk SMS with no recipients.');
throw new BadRequestException('No recipients provided.'); // Throw standard exception
}
// Format recipients for Twilio Notify bindings
// See: https://www.twilio.com/docs/notify/api/notification-resource#create-a-notification-resource
const bindings = recipients.map(number => {
return JSON.stringify({ binding_type: 'sms', address: number });
});
this.logger.log(`Sending bulk SMS via Notify to ${recipients.length} recipients.`);
try {
const notification = await this.twilioClient.notify
.services(this.notifyServiceSid)
.notifications.create({
toBinding: bindings,
body: messageBody,
});
this.logger.log(`Successfully created Notification resource: ${notification.sid}`);
return notification.sid;
} catch (error: any) { // Type assertion for error object
this.logger.error(`Failed to send bulk SMS via Twilio Notify: ${error.message}`, error.stack);
// Improved error handling based on Twilio error codes/status
if (error.code === 20003) { // Example: Auth error
throw new InternalServerErrorException('Twilio authentication failed. Check credentials.');
} else if (error.status === 429) { // Example: Rate limited by Twilio
this.logger.warn('Twilio rate limit hit. Consider implementing retry logic or queuing.');
throw new HttpException('Rate limit exceeded', HttpStatus.TOO_MANY_REQUESTS);
} else if (error.code === 21211 || error.code === 21604) { // Example: Invalid phone number or parameter
throw new BadRequestException(`Invalid parameter provided to Twilio: ${error.message}`);
}
// Default generic error
throw new InternalServerErrorException(`Failed to send bulk SMS. Error: ${error.message}`);
}
}
// --- Add other messaging methods here (e.g., sendSingleSms) if needed ---
}
Explanation:
- Dependencies: Injects
ConfigService
to read environment variables. UsesLogger
. ImportsBadRequestException
,InternalServerErrorException
,HttpException
,HttpStatus
. - Constructor:
- Retrieves Twilio credentials (
ACCOUNT_SID
,AUTH_TOKEN
) and theNOTIFY_SERVICE_SID
from the configuration. - Performs a crucial check to ensure these values are present, throwing an error if not.
- Initializes the
Twilio
client using the credentials.
- Retrieves Twilio credentials (
sendBulkSms
Method:- Takes an array of
recipients
(phone numbers in E.164 format, e.g.,+15551234567
) and themessageBody
. - Input Validation: Checks if the recipients array is empty and throws a
BadRequestException
if it is. - Binding Formatting: Maps the recipient phone numbers into the specific JSON structure required by the
toBinding
parameter of the Twilio Notify API ({ ""binding_type"": ""sms"", ""address"": number }
). This is the key step for telling Notify how and where to send the message for each recipient. - API Call: Uses the initialized
twilioClient
to callclient.notify.services(SERVICE_SID).notifications.create()
.toBinding
: Passes the formatted array of recipient bindings.body
: Passes the message content.
- Logging: Logs success and includes the Notification SID returned by Twilio. This SID is useful for tracking the broadcast status later.
- Error Handling: Wraps the API call in a
try...catch
block. Logs errors and throws specific exceptions based on Twilio error codes (20003
for auth,429
for rate limits,21211
/21604
for invalid numbers/params) or a genericInternalServerErrorException
.
- Takes an array of
5. Building the API Layer (MessagingController)
Now, let's expose the sendBulkSms
functionality via a REST API endpoint.
1. Create Data Transfer Object (DTO):
Define a DTO to specify the expected shape and validation rules for the incoming request body.
// src/messaging/dto/send-bulk-sms.dto.ts
import { IsArray, IsString, IsNotEmpty, ArrayNotEmpty, IsPhoneNumber } from 'class-validator';
// Remove swagger imports if not used, or keep if using Swagger
// import { ApiProperty } from '@nestjs/swagger';
export class SendBulkSmsDto {
@IsArray()
@ArrayNotEmpty()
@IsPhoneNumber(null, { each: true, message: 'Each recipient must be a valid phone number in E.164 format (e.g., +15551234567)' })
// @ApiProperty({
// description: 'An array of recipient phone numbers in E.164 format.',
// example: ['+15551234567', '+447700900123'],
// type: [String],
// })
recipients: string[];
@IsString()
@IsNotEmpty()
// @ApiProperty({
// description: 'The text content of the SMS message.',
// example: 'Hello from our NestJS app!',
// })
message: string;
}
Explanation:
- Uses
class-validator
decorators to enforce rules:@IsArray
,@ArrayNotEmpty
: Ensuresrecipients
is a non-empty array.@IsPhoneNumber(null, { each: true, ... })
: Validates that each string in therecipients
array is a valid phone number (useslibphonenumber-js
behind the scenes). Thenull
indicates region code is not fixed, relying on the E.164 format (e.g.,+15551234567
).@IsString
,@IsNotEmpty
: Ensuresmessage
is a non-empty string.
@ApiProperty
(Optional): If you integrate Swagger for API documentation, these decorators provide descriptions and examples. They are commented out here but can be uncommented if Swagger is used.
2. Implement the Controller:
Create the endpoint in MessagingController
.
// src/messaging/messaging.controller.ts
import { Controller, Post, Body, UsePipes, ValidationPipe, Logger, HttpCode, HttpStatus } from '@nestjs/common';
import { MessagingService } from './messaging.service';
import { SendBulkSmsDto } from './dto/send-bulk-sms.dto';
// Remove swagger imports if not used, or keep if using Swagger
// import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
// @ApiTags('Messaging') // Group endpoints in Swagger UI
@Controller('messaging')
export class MessagingController {
private readonly logger = new Logger(MessagingController.name);
constructor(private readonly messagingService: MessagingService) {}
@Post('bulk-sms')
@HttpCode(HttpStatus.ACCEPTED) // Use 202 Accepted as the process is async
// @ApiOperation({ summary: 'Send a bulk SMS message via Twilio Notify' })
// @ApiResponse({ status: HttpStatus.ACCEPTED, description: 'Request accepted, notification creation initiated.' })
// @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input data (e.g., missing fields, invalid phone numbers, empty recipients array).' })
// @ApiResponse({ status: HttpStatus.INTERNAL_SERVER_ERROR, description: 'Failed to initiate bulk SMS due to server or Twilio error.' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) // Apply validation pipe specifically here too (or rely on global)
async sendBulkSms(@Body() sendBulkSmsDto: SendBulkSmsDto): Promise<{ message: string; notificationSid?: string }> {
this.logger.log(`Received request to send bulk SMS to ${sendBulkSmsDto.recipients.length} recipients.`);
const { recipients, message } = sendBulkSmsDto;
try {
// Service now throws BadRequestException for empty recipients or invalid params,
// and HttpException/InternalServerErrorException for other issues.
// These will be handled by NestJS Exception Filters.
const notificationSid = await this.messagingService.sendBulkSms(recipients, message);
return {
message: 'Bulk SMS request accepted by Twilio Notify.',
notificationSid: notificationSid,
};
} catch (error) {
// If it's not a validation error caught by the pipe, or a specific exception
// thrown by the service, NestJS default exception filter will handle these.
// We re-throw it to let the framework handle the HTTP response.
this.logger.error(`Error processing bulk SMS request: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined);
throw error;
}
}
}
Explanation:
- Dependencies: Injects
MessagingService
. UsesLogger
. - Routing:
@Controller('messaging')
sets the base path for routes in this controller to/messaging
. - Endpoint:
@Post('bulk-sms')
: Defines a POST endpoint at/messaging/bulk-sms
.@HttpCode(HttpStatus.ACCEPTED)
: Sets the default success status code to 202 Accepted. This is appropriate because the Twilio Notify call initiates an asynchronous process.@ApiOperation
,@ApiResponse
(Optional): Swagger decorators for documentation (commented out).@UsePipes(...)
: Explicitly applies theValidationPipe
(though the global pipe inmain.ts
would also handle this).@Body() sendBulkSmsDto: SendBulkSmsDto
: Binds the incoming JSON request body to an instance ofSendBulkSmsDto
. TheValidationPipe
automatically validates this object. If validation fails, NestJS returns a 400 Bad Request response automatically.
- Logic:
- Logs the incoming request.
- Destructures the validated
recipients
andmessage
from the DTO. - Calls
this.messagingService.sendBulkSms
. - Returns a success response containing a message and the
notificationSid
. - If the service throws an error (including validation errors from the DTO,
BadRequestException
,HttpException
, orInternalServerErrorException
), it's caught, logged, and re-thrown. NestJS's built-in exception filters map these to appropriate HTTP responses (400, 429, 500, etc.).
3. (Optional) Setup Swagger:
If you want API documentation:
npm install @nestjs/swagger swagger-ui-express
# or
yarn add @nestjs/swagger swagger-ui-express
Then, configure it in src/main.ts
:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; // Import Swagger
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// Swagger Setup
const config = new DocumentBuilder()
.setTitle('Bulk SMS API')
.setDescription('API for sending bulk SMS messages via Twilio Notify')
.setVersion('1.0')
.addTag('Messaging') // Match the tag used in the controller (if uncommented there)
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document); // Serve docs at /api-docs
const configService = app.get(ConfigService);
const port = configService.get<number>('PORT') || 3000;
await app.listen(port);
console.log(`Application is running on: ${await app.getUrl()}`);
console.log(`Swagger Docs available at: ${await app.getUrl()}/api-docs`); // Log Swagger URL
}
bootstrap();
Now, when you run the app, you can access interactive API documentation at /api-docs
. Remember to uncomment the @ApiTags
, @ApiOperation
, @ApiResponse
, and @ApiProperty
decorators in the controller and DTO if you use Swagger.
6. Database Integration (Conceptual)
This section outlines the conceptual steps for database integration using Prisma. A full implementation is beyond the scope of this core guide but represents a typical next step.
A real-world application often needs to:
- Store Recipient Lists: Manage groups of users.
- Log Message Status: Track broadcast status (e.g., initiated, completed, failed).
1. Install Prisma:
npm install prisma @prisma/client --save-dev
# or
yarn add prisma @prisma/client -D
2. Initialize Prisma:
npx prisma init --datasource-provider postgresql # or your chosen DB
This creates a prisma
directory with schema.prisma
and updates .env
with DATABASE_URL
. Configure your DATABASE_URL
in .env
.
3. Define Schema:
// prisma/schema.prisma
generator client {
provider = ""prisma-client-js""
}
datasource db {
provider = ""postgresql""
url = env(""DATABASE_URL"")
}
model Recipient {
id String @id @default(cuid())
phoneNumber String @unique // E.164 format
firstName String?
lastName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add relationships to groups/lists if needed
// Example: recipientListId String?
// Example: recipientList RecipientList? @relation(fields: [recipientListId], references: [id])
}
// Example List Model
// model RecipientList {
// id String @id @default(cuid())
// name String @unique
// description String?
// recipients Recipient[]
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// }
model BulkMessageLog {
id String @id @default(cuid())
twilioNotifySid String @unique // The SID from Twilio Notify
messageBody String
status String @default(""initiated"") // e.g., initiated, completed, failed
recipientCount Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
4. Apply Migrations:
npx prisma migrate dev --name init-messaging-schema
5. Generate Prisma Client:
npx prisma generate
6. Create Prisma Service:
Abstract database interactions into a service.
nest generate service prisma --no-spec
// src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
this.logger.log('Prisma client connected.');
}
async onModuleDestroy() {
await this.$disconnect();
this.logger.log('Prisma client disconnected.');
}
// Add a logger for potential connection issues if needed
private readonly logger = new Logger(PrismaService.name);
}
Register PrismaService
in the modules where it's needed (e.g., MessagingModule
) and ensure PrismaModule
is created and imported globally or locally.
7. Use in Messaging Service:
Modify MessagingService
to:
- Optionally accept a list ID, fetch recipients from the DB using
PrismaService
. - Log the broadcast details to
BulkMessageLog
after initiating the send.
// Example modification in MessagingService
import { PrismaService } from '../prisma/prisma.service'; // Adjust path as needed
// ... inside MessagingService class
constructor(
private configService: ConfigService,
private prisma: PrismaService, // Inject PrismaService
) {
// ... Twilio setup
}
// Example method using DB lookup (conceptual)
async sendBulkSmsToList(listId: string, messageBody: string): Promise<string> {
// 1. Fetch recipients from DB based on listId (example)
const recipientsData = await this.prisma.recipient.findMany({
// where: { recipientListId: listId }, // Example filter if using lists
select: { phoneNumber: true }
});
const recipients = recipientsData.map(r => r.phoneNumber);
if (recipients.length === 0) {
throw new BadRequestException(`No recipients found for list ID: ${listId}`);
}
// 2. Call existing logic to send SMS
const notificationSid = await this.sendBulkSms(recipients, messageBody);
// 3. Log the attempt
try {
await this.prisma.bulkMessageLog.create({
data: {
twilioNotifySid: notificationSid,
messageBody: messageBody,
recipientCount: recipients.length,
status: 'initiated', // Initial status
},
});
this.logger.log(`Logged bulk message initiation for SID: ${notificationSid}`);
} catch (dbError) {
this.logger.error(`Failed to log bulk message initiation for SID ${notificationSid}: ${dbError instanceof Error ? dbError.message : String(dbError)}`, dbError instanceof Error ? dbError.stack : undefined);
// Decide how to handle logging failure: maybe just log the error and continue,
// or potentially throw an error if logging is critical.
}
return notificationSid;
}
Reminder: This is conceptual. A production system requires building out list management APIs and integrating status updates (likely via webhooks, see Section 10).
7. Error Handling, Logging, and Retry Mechanisms
Production systems need robust error handling and logging.
Error Handling:
- Validation Errors: Handled automatically by
ValidationPipe
(returns 400). - Empty Recipient Errors: Handled by
MessagingService
throwingBadRequestException
(returns 400). - Twilio API Errors: The
try...catch
block inMessagingService
catches errors. We've improved it to check specificerror.code
anderror.status
values from Twilio. See Twilio Error Codes for a full list. - Configuration Errors: The constructor check in
MessagingService
handles missing environment variables on startup.
Logging:
- We're using NestJS's built-in
Logger
. It logs to the console by default. - Production Logging: Integrate a more robust logging solution (e.g., Winston, Pino) and configure it to:
- Log to files or external services (Datadog, Sentry, ELK stack).
- Use JSON format for easier parsing.
- Include context (request ID, user ID if applicable).
- Adjust log levels (
debug
,info
,warn
,error
).
- Key Logging Points:
- Incoming requests (
MessagingController
). - Configuration loading/validation (
MessagingService
constructor). - Initiation of bulk send with recipient count (
MessagingService
). - Successful notification creation with SID (
MessagingService
). - Errors during Twilio API calls with details (
MessagingService
). - Database logging successes/failures (if implemented).
- Empty recipient list warnings (
MessagingService
).
- Incoming requests (
Retry Mechanisms:
- Twilio API calls can fail transiently (network issues, temporary Twilio hiccups, rate limits -
429
). - Considerations:
- Idempotency: Twilio Notify's
create
operation is generally not idempotent. Retrying the samecreate
call might result in duplicate broadcasts. - Strategy: Instead of retrying the
create
call directly for transient errors, consider:- Queuing: Implement a background job queue (e.g., BullMQ, RabbitMQ). If the initial API call fails transiently, place the job in the queue to be retried later with exponential backoff.
- Client-Side Retry: The client calling your API could implement retries if they receive a
5xx
or429
response. - Status Check & Resend: If a call fails, log the failure. A separate process could later check the status of intended recipients (if tracked) and attempt to resend only to those who didn't receive the message (more complex).
- Idempotency: Twilio Notify's
- Simple Retry (Use with Caution): If you must implement a simple retry within the service (e.g., for immediate network errors), use a library like
async-retry
or implement carefully with backoff. Avoid retrying for non-transient errors (auth, bad parameters) or potentially duplicate-creating operations like Notifycreate
unless you have specific logic to handle it.
// Conceptual retry logic using a hypothetical library or manual implementation
// Place this logic *around* the call to sendBulkSms in the controller, or use a queue.
// Avoid direct retry of `notifications.create` inside `MessagingService` due to idempotency issues.
// Example using a queue (conceptual - requires setting up BullMQ or similar)
// In MessagingController:
// constructor(
// private readonly messagingService: MessagingService,
// @InjectQueue('message-queue') private messageQueue: Queue, // Inject queue
// ) {}
// async sendBulkSms(@Body() sendBulkSmsDto: SendBulkSmsDto) {
// this.logger.log(`Queueing request to send bulk SMS to ${sendBulkSmsDto.recipients.length} recipients.`);
// await this.messageQueue.add('send-bulk-sms-job', {
// recipients: sendBulkSmsDto.recipients,
// message: sendBulkSmsDto.message,
// }, {
// attempts: 3, // Max attempts
// backoff: { type: 'exponential', delay: 1000 }, // Exponential backoff
// });
// return { message: 'Bulk SMS request queued for processing.' };
// // HttpStatus should likely remain 202 Accepted
// }
// Then create a Queue Processor that calls messagingService.sendBulkSms
For production, using a dedicated job queue is the most robust approach for handling retries of potentially non-idempotent operations like sending notifications.