Building Production-Ready SMS Marketing Campaigns with NestJS and Vonage
This guide provides a step-by-step walkthrough for building a robust SMS marketing campaign application using NestJS, TypeScript, TypeORM, PostgreSQL, and the Vonage Messages API. We will cover everything from initial project setup and Vonage configuration to implementing core features, handling webhooks, ensuring security, and deploying the application.
By the end of this tutorial, you will have a functional NestJS application capable of:
- Managing marketing campaigns and subscriber lists.
- Sending bulk SMS messages via the Vonage Messages API.
- Receiving inbound SMS messages (e.g., STOP replies).
- Tracking message delivery statuses.
- Implementing basic security, error handling, and logging.
This guide aims to be a complete reference, enabling developers to build and deploy a production-ready system, following the specified guidelines and structure.
Prerequisites:
- Node.js and npm/yarn: Ensure you have a recent LTS version installed.
- NestJS CLI: Install globally:
npm install -g @nestjs/cli
- Docker and Docker Compose: For running PostgreSQL locally and containerizing the application.
- Vonage API Account: Sign up for free at vonage.com. You'll need your Application ID and Private Key.
- Vonage Phone Number: Purchase an SMS-capable number from your Vonage dashboard.
- ngrok: Useful for testing webhooks locally. Sign up for a free account at ngrok.com and install it. Note: For production environments, you will need a stable, publicly accessible URL for your webhook endpoints. This could be your deployed application's URL on a server, a PaaS, or a dedicated tunneling service with a stable address. Using a staging environment that mirrors production is recommended for testing before deploying live.
1. Setting Up the NestJS Project
Let's initialize our NestJS project and install the necessary dependencies.
-
Create a New NestJS Project: Open your terminal and run:
nest new vonage-sms-campaign-app cd vonage-sms-campaign-app
Choose your preferred package manager (npm or yarn).
-
Install Dependencies: We need modules for configuration, database interaction (TypeORM with PostgreSQL), validation, the Vonage SDK, rate limiting, and health checks.
# Core NestJS & Utility Modules npm install @nestjs/config @nestjs/typeorm typeorm pg class-validator class-transformer dotenv # Vonage SDK npm install @vonage/server-sdk # Optional but Recommended for Production npm install @nestjs/terminus nestjs-rate-limiter helmet # Health checks, rate limiting, security headers npm install --save-dev @types/helmet # Types for helmet
@nestjs/config
: Manages environment variables.@nestjs/typeorm
,typeorm
,pg
: For PostgreSQL database integration using TypeORM.class-validator
,class-transformer
: For request data validation using DTOs.dotenv
: Loads environment variables from a.env
file.@vonage/server-sdk
: The official Vonage Node.js SDK.@nestjs/terminus
: Implements health check endpoints.nestjs-rate-limiter
: Adds rate limiting to protect endpoints.helmet
: Sets various HTTP headers for security.
-
Project Structure (Conceptual): NestJS promotes a modular structure. We'll organize our code logically:
vonage-sms-campaign-app/ ├── src/ │ ├── app.module.ts # Root module │ ├── main.ts # Application entry point │ ├── config/ # Configuration setup (env vars) │ ├── database/ # Database connection, entities, migrations │ ├── campaigns/ # Campaign management module (controller, service, entity) │ ├── subscribers/ # Subscriber management module (controller, service, entity) │ ├── vonage/ # Vonage integration module (service, config) │ ├── webhooks/ # Webhook handling module (controller, service) │ ├── common/ # Shared utilities, filters, interceptors │ └── health/ # Health check module ├── .env # Environment variables (DO NOT COMMIT SENSITIVE DATA) ├── .env.example # Example environment variables ├── ormconfig.json # TypeORM configuration (optional, can be in AppModule) ├── Dockerfile ├── docker-compose.yml └── ... (other NestJS files)
2. Vonage Account and Application Setup
Before writing code, configure your Vonage account and application correctly. This is critical for authentication and receiving webhooks.
- Access Vonage Dashboard: Log in to your Vonage API Dashboard.
- API Settings (Crucial):
- Navigate to
API settings
in the left-hand menu. - Scroll down to the
SMS settings
section. - Under
Default SMS Setting
, ensureMessages API
is selected. This determines the webhook format and authentication method we'll use. Save changes if necessary.
- Navigate to
- Create a Vonage Application:
- Go to
Applications
->Create a new application
. - Give it a name (e.g.,
NestJS SMS Campaigns
). - Enable the
Messages
capability. - You'll need webhook URLs. For now, enter temporary placeholders like
http://example.com/webhooks/inbound
for the Inbound URL andhttp://example.com/webhooks/status
for the Status URL. We will update these later with our ngrok URL or public deployment URL. - Click
Generate public and private key
. Save theprivate.key
file securely within your project directory (e.g., at the root). Do not commit this file to Git. Addprivate.key
to your.gitignore
file. - Note down the Application ID displayed after creation.
- Go to
- Link Your Vonage Number:
- Go to
Numbers
->Your numbers
. - Find the SMS-capable number you purchased.
- Click
Manage
orLink
next to the number. - Select the application you just created (
NestJS SMS Campaigns
) from the dropdown and save. This directs incoming messages for this number to your application's webhooks.
- Go to
.env
and ConfigModule
)
3. Environment Configuration (Securely manage API keys and other configuration settings using environment variables.
-
Create
.env
and.env.example
files: In the project root, create.env
(for your local values) and.env.example
(as a template). Add.env
to your.gitignore
.File:
.env.example
# Application PORT=3000 NODE_ENV=development # Vonage Configuration VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID VONAGE_PRIVATE_KEY_PATH=./private.key # Relative path to your downloaded key VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # e.g., 14155550100 # Database (PostgreSQL) DATABASE_HOST=localhost DATABASE_PORT=5432 DATABASE_USERNAME=your_db_user DATABASE_PASSWORD=your_db_password DATABASE_NAME=sms_campaigns_db # ngrok URL (update when running ngrok for local testing) or Public URL BASE_URL=http://localhost:3000
File:
.env
# Fill with your actual values PORT=3000 NODE_ENV=development VONAGE_APPLICATION_ID=abc123xyz... VONAGE_PRIVATE_KEY_PATH=./private.key VONAGE_NUMBER=14155550100 DATABASE_HOST=localhost DATABASE_PORT=5432 DATABASE_USERNAME=postgres # Default for docker-compose setup later DATABASE_PASSWORD=secret DATABASE_NAME=sms_campaigns_db BASE_URL=http://localhost:3000 # Will be replaced by ngrok URL during development or public URL in deployment
-
Integrate
ConfigModule
: Configure theConfigModule
in your rootAppModule
to load these variables globally.File:
src/app.module.ts
import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; // Import later // Other module imports... @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Make ConfigService available globally envFilePath: '.env', // Specify the env file }), // TypeOrmModule will be added here later // Other modules... ], controllers: [], providers: [], }) export class AppModule {}
4. Database Schema and Data Layer (TypeORM & PostgreSQL)
We'll use TypeORM to define our entities and interact with a PostgreSQL database.
-
Docker Compose for PostgreSQL: Create a
docker-compose.yml
file in the project root for easy local database setup.File:
docker-compose.yml
version: '3.8' services: postgres: image: postgres:15-alpine container_name: vonage_sms_postgres environment: POSTGRES_USER: ${DATABASE_USERNAME:-postgres} # Use .env or default POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-secret} POSTGRES_DB: ${DATABASE_NAME:-sms_campaigns_db} ports: - ""${DATABASE_PORT:-5432}:5432"" volumes: - postgres_data:/var/lib/postgresql/data restart: unless-stopped volumes: postgres_data: driver: local
Run
docker-compose up -d
to start the PostgreSQL container. -
Define Entities: Create entity files for
Campaign
,Subscriber
, andMessageLog
.File:
src/database/entities/subscriber.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; export enum SubscriptionStatus { SUBSCRIBED = 'subscribed', UNSUBSCRIBED = 'unsubscribed', } @Entity('subscribers') export class Subscriber { @PrimaryGeneratedColumn('uuid') id: string; @Index({ unique: true }) @Column({ type: 'varchar', length: 20 }) phoneNumber: string; // E.164 format recommended @Column({ type: 'varchar', length: 100, nullable: true }) firstName?: string; @Column({ type: 'varchar', length: 100, nullable: true }) lastName?: string; @Column({ type: 'enum', enum: SubscriptionStatus, default: SubscriptionStatus.SUBSCRIBED, }) status: SubscriptionStatus; @CreateDateColumn() subscribedAt: Date; @UpdateDateColumn() updatedAt: Date; @Column({ type: 'timestamp', nullable: true }) unsubscribedAt?: Date; }
File:
src/database/entities/campaign.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany } from 'typeorm'; import { MessageLog } from './message-log.entity'; export enum CampaignStatus { DRAFT = 'draft', SCHEDULED = 'scheduled', SENDING = 'sending', SENT = 'sent', FAILED = 'failed', } @Entity('campaigns') export class Campaign { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'varchar', length: 255 }) name: string; @Column({ type: 'text' }) messageTemplate: string; @Column({ type: 'enum', enum: CampaignStatus, default: CampaignStatus.DRAFT, }) status: CampaignStatus; @CreateDateColumn() createdAt: Date; @Column({ type: 'timestamp', nullable: true }) scheduledAt?: Date; @Column({ type: 'timestamp', nullable: true }) sentAt?: Date; // Relation: A campaign can have many message logs @OneToMany(() => MessageLog, (log) => log.campaign) messageLogs: MessageLog[]; }
File:
src/database/entities/message-log.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, Index } from 'typeorm'; import { Campaign } from './campaign.entity'; import { Subscriber } from './subscriber.entity'; export enum MessageStatus { PENDING = 'pending', // Initial state before sending attempt SUBMITTED = 'submitted', // Successfully submitted to Vonage DELIVERED = 'delivered', // Confirmed delivery EXPIRED = 'expired', // Message expired before delivery FAILED = 'failed', // Delivery failed REJECTED = 'rejected', // Rejected by carrier or Vonage UNKNOWN = 'unknown', // Status unknown or not yet received } @Entity('message_logs') @Index(['vonageMessageId']) // Index for quick lookup via webhook export class MessageLog { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'varchar', length: 50, nullable: true }) // Vonage Message UUID vonageMessageId?: string; @ManyToOne(() => Campaign, (campaign) => campaign.messageLogs, { onDelete: 'CASCADE' }) campaign: Campaign; @ManyToOne(() => Subscriber, { eager: true, onDelete: 'CASCADE' }) // Eager load subscriber for easy access subscriber: Subscriber; @Column({ type: 'enum', enum: MessageStatus, default: MessageStatus.PENDING, }) status: MessageStatus; @Column({ type: 'text', nullable: true }) errorMessage?: string; // Store error details from Vonage @CreateDateColumn() createdAt: Date; // When we created the log/attempted send @UpdateDateColumn() updatedAt: Date; // When the status was last updated (e.g., via webhook) @Column({ type: 'timestamp', nullable: true }) submittedAt?: Date; // Timestamp from Vonage status webhook @Column({ type: 'timestamp', nullable: true }) deliveredAt?: Date; // Timestamp from Vonage status webhook }
-
Configure TypeORM Connection: Update
AppModule
to configure the database connection usingConfigService
.File:
src/app.module.ts
import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Subscriber } from './database/entities/subscriber.entity'; import { Campaign } from './database/entities/campaign.entity'; import { MessageLog } from './database/entities/message-log.entity'; // Other imports... // Import your feature modules here later import { CampaignsModule } from './campaigns/campaigns.module'; import { SubscribersModule } from './subscribers/subscribers.module'; import { VonageModule } from './vonage/vonage.module'; import { WebhooksModule } from './webhooks/webhooks.module'; import { HealthModule } from './health/health.module'; import { RateLimiterModule } from 'nestjs-rate-limiter'; // Import RateLimiterModule if used globally @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }), TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ type: 'postgres', host: configService.get<string>('DATABASE_HOST'), port: configService.get<number>('DATABASE_PORT'), username: configService.get<string>('DATABASE_USERNAME'), password: configService.get<string>('DATABASE_PASSWORD'), database: configService.get<string>('DATABASE_NAME'), entities: [Subscriber, Campaign, MessageLog], synchronize: configService.get<string>('NODE_ENV') !== 'production', // Auto-create schema in dev, use migrations in prod logging: configService.get<string>('NODE_ENV') !== 'production', // migrations: [__dirname + '/database/migrations/*{.ts,.js}'], // Configure for production // cli: { migrationsDir: 'src/database/migrations' }, // Configure for production }), inject: [ConfigService], }), // Feature Modules CampaignsModule, SubscribersModule, VonageModule, // Ensure VonageModule is Global or imported where needed WebhooksModule, HealthModule, // RateLimiterModule // Register RateLimiterModule if using it globally ], // ... controllers/providers if any at root level }) export class AppModule {}
Note: For production, set
synchronize: false
and use TypeORM migrations. Refer to the NestJS TypeORM documentation for migration setup. For this guide,synchronize: true
simplifies local development. -
Create Modules for Entities: Generate modules and services for
Campaigns
andSubscribers
.nest g module campaigns nest g service campaigns nest g controller campaigns nest g module subscribers nest g service subscribers nest g controller subscribers
Import
TypeOrmModule.forFeature([...])
intoCampaignsModule
andSubscribersModule
to inject repositories.File:
src/campaigns/campaigns.module.ts
import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CampaignsService } from './campaigns.service'; import { CampaignsController } from './campaigns.controller'; import { Campaign } from '../database/entities/campaign.entity'; import { MessageLog } from '../database/entities/message-log.entity'; import { Subscriber } from '../database/entities/subscriber.entity'; // Import Subscriber if needed by service // VonageModule is Global, so no need to import here unless it wasn't global @Module({ imports: [ TypeOrmModule.forFeature([Campaign, MessageLog, Subscriber]), // Add Subscriber repo if needed ], controllers: [CampaignsController], providers: [CampaignsService], exports: [CampaignsService], // Export if needed by other modules }) export class CampaignsModule {}
File:
src/subscribers/subscribers.module.ts
import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { SubscribersService } from './subscribers.service'; import { SubscribersController } from './subscribers.controller'; import { Subscriber } from '../database/entities/subscriber.entity'; @Module({ imports: [TypeOrmModule.forFeature([Subscriber])], controllers: [SubscribersController], providers: [SubscribersService], exports: [SubscribersService], // Export if needed by other modules }) export class SubscribersModule {}
5. Implementing Core Functionality (Vonage Service & Campaign Sending)
Now, let's integrate the Vonage SDK and implement the logic to send campaign messages.
-
Create Vonage Module & Service: Encapsulate Vonage SDK initialization and interaction.
nest g module vonage nest g service vonage
File:
src/vonage/vonage.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Vonage } from '@vonage/server-sdk'; import { MessageSendRequest } from '@vonage/messages'; // Import specific types import * as fs from 'fs'; // Import fs to read the private key import * as path from 'path'; // Import path for resolving paths @Injectable() export class VonageService implements OnModuleInit { private readonly logger = new Logger(VonageService.name); private vonageClient: Vonage; private vonageNumber: string; constructor(private configService: ConfigService) {} onModuleInit() { const applicationId = this.configService.get<string>('VONAGE_APPLICATION_ID'); const privateKeyPath = this.configService.get<string>('VONAGE_PRIVATE_KEY_PATH'); this.vonageNumber = this.configService.get<string>('VONAGE_NUMBER'); if (!applicationId || !privateKeyPath || !this.vonageNumber) { this.logger.error('Vonage credentials not configured correctly in .env'); throw new Error('Vonage credentials missing or invalid.'); } try { // Resolve path relative to the project root (process.cwd()) const resolvedPath = path.resolve(process.cwd(), privateKeyPath); if (!fs.existsSync(resolvedPath)) { throw new Error(`Private key file not found at: ${resolvedPath}`); } const privateKey = fs.readFileSync(resolvedPath); // Read the key file this.vonageClient = new Vonage({ applicationId: applicationId, privateKey: privateKey, // Pass the key content }); this.logger.log('Vonage client initialized successfully.'); } catch (error) { this.logger.error(`Failed to initialize Vonage client: ${error.message}`, error.stack); throw error; // Prevent application startup if Vonage SDK fails } } async sendSms(to: string, text: string): Promise<{ success: boolean; messageId?: string; error?: any }> { if (!this.vonageClient) { this.logger.error('Vonage client not initialized.'); return { success: false, error: 'Vonage client not initialized.' }; } const messageRequest: MessageSendRequest = { message_type: 'text', channel: 'sms', to: to, // E.164 format from: this.vonageNumber, // Your Vonage number text: text, }; try { this.logger.log(`Attempting to send SMS to ${to}`); const response = await this.vonageClient.messages.send(messageRequest); this.logger.log(`SMS submitted to Vonage for ${to}. Message UUID: ${response.message_uuid}`); return { success: true, messageId: response.message_uuid }; } catch (error) { // Log the detailed error from the Vonage SDK this.logger.error(`Failed to send SMS to ${to}: ${error?.response?.data?.title || error.message}`, error?.response?.data || error); return { success: false, error: error?.response?.data || error }; } } getClient(): Vonage { return this.vonageClient; } // Placeholder for JWT verification - needs implementation async verifyWebhookJwt(token: string, rawBody: Buffer): Promise<boolean> { this.logger.warn('verifyWebhookJwt method called but not implemented.'); // Implementation would involve: // 1. Getting the public key or secret from config/Vonage settings. // 2. Using `this.vonageClient.verifyWebhookJwt` or a similar JWT library. // Example using potential SDK method (adjust based on actual SDK capabilities): /* try { // Note: Vonage Messages API webhooks typically use signed requests (HMAC-SHA256) // rather than JWTs for the main payload verification. // Check Vonage documentation for the correct verification method for Messages API webhooks. // If using JWT for authentication *before* processing, the logic would go here. // If verifying the signature header: // const signatureSecret = this.configService.get<string>('VONAGE_SIGNATURE_SECRET'); // const isValid = this.vonageClient.messages.verifySignature(token, rawBody, signatureSecret); // Hypothetical method // return isValid; } catch (err) { this.logger.error(`Webhook verification failed: ${err.message}`); return false; } */ return false; // Return false until implemented correctly } }
File:
src/vonage/vonage.module.ts
import { Module, Global } from '@nestjs/common'; import { VonageService } from './vonage.service'; import { ConfigModule } from '@nestjs/config'; // Ensure ConfigModule is available @Global() // Make VonageService available globally without importing VonageModule everywhere @Module({ imports: [ConfigModule], // VonageService depends on ConfigService providers: [VonageService], exports: [VonageService], }) export class VonageModule {}
Make sure
VonageModule
is imported intoAppModule
's imports array (done in step 4.3). -
Implement Campaign Sending Logic: Add a method in
CampaignsService
to fetch subscribers and send messages usingVonageService
.File:
src/campaigns/campaigns.service.ts
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Campaign, CampaignStatus } from '../database/entities/campaign.entity'; import { Subscriber, SubscriptionStatus } from '../database/entities/subscriber.entity'; import { MessageLog, MessageStatus } from '../database/entities/message-log.entity'; import { VonageService } from '../vonage/vonage.service'; import { CreateCampaignDto } from './dto/create-campaign.dto'; // Define this DTO later @Injectable() export class CampaignsService { private readonly logger = new Logger(CampaignsService.name); constructor( @InjectRepository(Campaign) private campaignsRepository: Repository<Campaign>, @InjectRepository(Subscriber) private subscribersRepository: Repository<Subscriber>, @InjectRepository(MessageLog) private messageLogRepository: Repository<MessageLog>, // VonageService is injected because VonageModule is Global private vonageService: VonageService, ) {} async create(createCampaignDto: CreateCampaignDto): Promise<Campaign> { const campaign = this.campaignsRepository.create(createCampaignDto); campaign.status = CampaignStatus.DRAFT; return this.campaignsRepository.save(campaign); } findAll(): Promise<Campaign[]> { return this.campaignsRepository.find(); } async findOne(id: string): Promise<Campaign> { const campaign = await this.campaignsRepository.findOne({ where: { id } }); if (!campaign) { // Corrected template literal usage throw new NotFoundException(`Campaign with ID "${id}" not found`); } return campaign; } async sendCampaign(campaignId: string): Promise<void> { const campaign = await this.findOne(campaignId); if (campaign.status !== CampaignStatus.DRAFT && campaign.status !== CampaignStatus.FAILED) { this.logger.warn(`Campaign ${campaignId} is not in DRAFT or FAILED status. Current status: ${campaign.status}`); // Optionally throw an error or just return return; } // Find subscribed users const subscribers = await this.subscribersRepository.find({ where: { status: SubscriptionStatus.SUBSCRIBED }, }); if (subscribers.length === 0) { this.logger.warn(`No subscribed users found for campaign ${campaignId}.`); campaign.status = CampaignStatus.FAILED; // Or SENT if technically no messages needed sending campaign.sentAt = new Date(); await this.campaignsRepository.save(campaign); return; } this.logger.log(`Starting campaign ${campaignId} (${campaign.name}) for ${subscribers.length} subscribers.`); campaign.status = CampaignStatus.SENDING; await this.campaignsRepository.save(campaign); let successCount = 0; let failureCount = 0; // --- Simple Sequential Sending (See Performance section for improvements) --- for (const subscriber of subscribers) { // Simple check to avoid sending to unsubscribed (though filtered above, good practice) if (subscriber.status !== SubscriptionStatus.SUBSCRIBED) { this.logger.warn(`Skipping unsubscribed user ${subscriber.phoneNumber} for campaign ${campaignId}`); continue; } const messageLog = this.messageLogRepository.create({ campaign: campaign, subscriber: subscriber, status: MessageStatus.PENDING, }); await this.messageLogRepository.save(messageLog); // Save log before sending try { // Basic placeholder substitution (replace with a more robust method if needed) const personalizedMessage = campaign.messageTemplate .replace('{firstName}', subscriber.firstName || '') .replace('{lastName}', subscriber.lastName || '') .trim(); const result = await this.vonageService.sendSms( subscriber.phoneNumber, personalizedMessage, ); if (result.success && result.messageId) { messageLog.status = MessageStatus.SUBMITTED; messageLog.vonageMessageId = result.messageId; messageLog.submittedAt = new Date(); successCount++; } else { messageLog.status = MessageStatus.FAILED; messageLog.errorMessage = JSON.stringify(result.error || 'Unknown error during submission'); failureCount++; } } catch (error) { this.logger.error(`Critical error sending to ${subscriber.phoneNumber}: ${error.message}`, error.stack); messageLog.status = MessageStatus.FAILED; messageLog.errorMessage = `Internal error: ${error.message}`; failureCount++; } await this.messageLogRepository.save(messageLog); // Update log with status and messageId/error // Basic rate limiting - Vonage default is often 1 SMS/sec for long codes // Consider making the delay configurable or using a more sophisticated queue/batching system await new Promise(resolve => setTimeout(resolve, 1100)); // ~1.1 second delay } // --- End Simple Sequential Sending --- campaign.status = failureCount > 0 ? CampaignStatus.FAILED : CampaignStatus.SENT; // Mark SENT only if all submitted successfully campaign.sentAt = new Date(); await this.campaignsRepository.save(campaign); this.logger.log(`Campaign ${campaignId} finished. Submitted: ${successCount}, Failed: ${failureCount}`); } // ... other methods (update, delete) }
6. Building the API Layer (Controllers and DTOs)
Expose endpoints to manage campaigns and subscribers.
-
Define Data Transfer Objects (DTOs) with Validation: Use
class-validator
decorators for robust input validation.File:
src/subscribers/dto/create-subscriber.dto.ts
import { IsString, IsNotEmpty, IsPhoneNumber, IsOptional, MaxLength } from 'class-validator'; export class CreateSubscriberDto { @IsNotEmpty() @IsPhoneNumber(null) // Use null for international format validation (e.g., +14155550100) @MaxLength(20) phoneNumber: string; @IsOptional() @IsString() @MaxLength(100) firstName?: string; @IsOptional() @IsString() @MaxLength(100) lastName?: string; }
File:
src/campaigns/dto/create-campaign.dto.ts
import { IsString, IsNotEmpty, MinLength, MaxLength, IsOptional, IsDateString } from 'class-validator'; export class CreateCampaignDto { @IsNotEmpty() @IsString() @MinLength(3) @MaxLength(255) name: string; @IsNotEmpty() @IsString() @MinLength(10) messageTemplate: string; @IsOptional() @IsDateString() scheduledAt?: string; // ISO 8601 date string }
-
Implement Controllers: Create endpoints using the services and DTOs. Ensure
ValidationPipe
is enabled globally.File:
src/main.ts
import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { Logger, ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import helmet from 'helmet'; // Import helmet import { NestExpressApplication } from '@nestjs/platform-express'; // Import this for rawBody option import * as bodyParser from 'body-parser'; // Import body-parser async function bootstrap() { // Specify NestExpressApplication for access to Express-specific options like bodyParser const app = await NestFactory.create<NestExpressApplication>(AppModule, { // Enable bodyParser to access raw request body for webhook verification bodyParser: false, // Disable NestJS default body parsing temporarily }); const configService = app.get(ConfigService); const port = configService.get<number>('PORT') || 3000; const nodeEnv = configService.get<string>('NODE_ENV'); // Apply Helmet middleware for security headers app.use(helmet()); // Enable CORS if your frontend/API clients are on different origins app.enableCors(); // Configure origins appropriately for production // Re-enable JSON body parsing for regular routes, keeping raw body for specific routes // Parse JSON bodies app.use(bodyParser.json()); // Parse URL-encoded bodies app.use(bodyParser.urlencoded({ extended: true })); // Crucially, parse raw body specifically for webhook routes *before* JSON/URL parsing if needed // This setup assumes webhooks might need raw body, other routes need parsed body. // A more robust approach might involve applying raw body parsing only to webhook routes. // Example: app.use('/webhooks', bodyParser.raw({ type: 'application/json' })); // For simplicity here, we rely on accessing it if bodyParser hasn't consumed it. // NestJS's built-in `rawBody: true` in `NestFactory.create` is often simpler if applicable globally. // Let's revert to the simpler NestJS way if it works for Vonage webhooks: // Remove `bodyParser: false` from create options. // Remove `app.use(bodyParser...)` lines. // Add `rawBody: true` to NestFactory.create options: // const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true }); // 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 non-whitelisted properties are present })); await app.listen(port); Logger.log(`🚀 Application is running on: http://localhost:${port} in ${nodeEnv} mode`, 'Bootstrap'); Logger.log(`📄 Swagger UI available at http://localhost:${port}/api`, 'Bootstrap'); // If Swagger is setup } bootstrap();
(Note: The
main.ts
example above shows how to handlerawBody
for webhooks. Ensure your final implementation correctly provides the raw body to your webhook verification logic, potentially usingNestFactory.create(AppModule, { rawBody: true })
and accessingreq.rawBody
in the controller, or using middleware as shown.)(Implement
SubscribersController
andCampaignsController
with appropriate methods using@Get
,@Post
,@Param
,@Body
, etc., calling the respective service methods and using the DTOs.)