code examples
code examples
Production-Ready SMS Scheduling with NestJS and Vonage
A step-by-step guide to building a robust SMS scheduling and reminder system using NestJS, TypeORM, PostgreSQL, and the Vonage Messages API.
This guide provides a step-by-step walkthrough for building a robust SMS scheduling and reminder system using the NestJS framework and the Vonage Messages API. We will cover everything from initial project setup to deployment considerations, enabling you to reliably send time-sensitive SMS notifications.
By the end of this tutorial, you will have a NestJS application capable of:
- Accepting API requests to schedule SMS messages for a future date and time.
- Persisting scheduled messages in a database.
- Running a background task to check for due messages.
- Sending scheduled SMS messages reliably via the Vonage Messages API.
- Handling errors and logging appropriately.
This guide assumes you have a basic understanding of Node.js, TypeScript, and REST APIs. Familiarity with the NestJS framework is helpful but not strictly required, as we will explain concepts along the way.
Project Overview and Goals
Problem: Many applications need to send notifications or reminders via SMS at specific future times – appointment reminders, event notifications, follow-up messages, etc. Building this reliably requires careful handling of scheduling logic, persistence, and integration with an SMS provider.
Solution: We will build a dedicated NestJS service that exposes an API endpoint to schedule SMS messages. It will use a database (PostgreSQL) to store these scheduled messages and employ a task scheduler (@nestjs/schedule) to periodically check for and send messages that are due, leveraging the Vonage Messages API for delivery.
Technologies:
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications using TypeScript. Its modular architecture and dependency injection system make it ideal for this task.
- Vonage Messages API: A powerful API for sending and receiving messages across various channels, including SMS. We'll use its Node.js SDK (
@vonage/server-sdk). - TypeORM: An Object-Relational Mapper (ORM) for TypeScript and JavaScript. It simplifies database interactions. (Alternatively, Prisma could be used).
- PostgreSQL: A robust open-source relational database for persisting scheduled jobs.
@nestjs/schedule: A built-in NestJS module for declarative task scheduling (cron jobs, timeouts, intervals).@nestjs/config: For managing environment variables securely and efficiently.class-validator&class-transformer: For robust request data validation.
System Architecture:
graph LR
Client[Client Application] -- HTTP POST /schedule --> API{NestJS API Endpoint};
API -- Validates & Creates Job --> DB[(PostgreSQL Database)];
Scheduler(NestJS Scheduler @Cron) -- Checks for Due Jobs --> DB;
Scheduler -- Finds Due Job --> Sender[Vonage Service];
Sender -- Sends SMS via API --> Vonage[Vonage Messages API];
Vonage -- Delivers SMS --> UserDevice[User's Phone];
API -- Logs Events --> Logger{Logging System};
Sender -- Logs Events/Errors --> Logger;
Scheduler -- Logs Events/Errors --> Logger;
subgraph NestJS Application
API
Scheduler
Sender
DBInteraction[Database Service/Repository]
end
API -- Uses --> DBInteraction;
Scheduler -- Uses --> DBInteraction;
Scheduler -- Triggers --> Sender;
DBInteraction -- Interacts --> DB;Prerequisites:
- Node.js (LTS version recommended, e.g., v18 or v20)
- npm or yarn package manager
- A Vonage API account (Sign up here for free credit)
- A Vonage phone number capable of sending SMS (Purchase via the Vonage Dashboard)
- Access to a PostgreSQL database (local instance or cloud-based)
- Basic familiarity with terminal/command line usage
- Optional: Docker for containerized development/deployment
1. Setting up the Project
Let's initialize our NestJS project and install the necessary dependencies.
1.1 Install NestJS CLI:
If you don't have the NestJS CLI installed globally, run:
npm install -g @nestjs/cli1.2 Create New NestJS Project:
nest new vonage-sms-scheduler
cd vonage-sms-schedulerChoose your preferred package manager (npm or yarn) when prompted.
1.3 Install Dependencies:
We need several packages for configuration, database interaction, scheduling, validation, and Vonage integration.
# Core NestJS modules + Vonage SDK
npm install @nestjs/config @nestjs/schedule @nestjs/typeorm typeorm pg @vonage/server-sdk class-validator class-transformer
# Development dependencies (TypeORM migrations require these)
npm install --save-dev ts-node tsconfig-paths1.4 Configure Environment Variables:
Create a .env file in the project root. This file will store sensitive credentials and configuration settings. Never commit this file to version control. Add a .gitignore entry for .env if it's not already there.
# .env
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=your_db_user
DB_PASSWORD=your_db_password
DB_DATABASE=vonage_scheduler
# Vonage API Credentials
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root
VONAGE_FROM_NUMBER=YOUR_VONAGE_PHONE_NUMBER # Number purchased from Vonage
# Application Settings
PORT=3000
TZ=UTC # Explicitly set timezone for scheduling consistency- Replace placeholder database credentials with your actual PostgreSQL details.
- Obtain Vonage credentials as detailed in section 4.
- Setting
TZ=UTCis crucial for consistent scheduling across different server environments.
1.5 Set up Configuration Module:
NestJS provides a ConfigModule to load and manage environment variables.
Modify src/app.module.ts:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
// We will create these modules later
// import { SchedulingModule } from './scheduling/scheduling.module';
// import { VonageModule } from './vonage/vonage.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Make ConfigService available globally
envFilePath: '.env',
}),
ScheduleModule.forRoot(), // Initialize the scheduler
// TypeOrmModule setup will be added in the database section
// SchedulingModule, // Will be added later
// VonageModule, // Will be added later
],
controllers: [AppController], // Default controller, can be removed if not needed
providers: [AppService], // Default service, can be removed if not needed
})
export class AppModule {}1.6 Basic Project Structure:
NestJS encourages a modular structure. We'll create modules for specific features:
scheduling: Handles API endpoints, scheduling logic, and database interaction for messages.vonage: Encapsulates interaction with the Vonage SDK.
(We will create the files for these modules in the relevant sections).
2. Creating a Database Schema and Data Layer
We need a way to store the scheduled SMS messages. We'll use TypeORM and PostgreSQL.
2.1 Define the Entity:
Create a file for our database entity.
// src/scheduling/entities/scheduled-message.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum MessageStatus {
PENDING = 'PENDING',
SENT = 'SENT',
FAILED = 'FAILED',
PROCESSING = 'PROCESSING', // Optional: To prevent multiple workers picking the same job
}
@Entity('scheduled_messages')
export class ScheduledMessage {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
recipient: string; // E.164 format recommended (e.g., +15551234567)
@Column('text')
message: string;
@Column({ type: 'timestamp with time zone' })
@Index() // Index for efficient querying
sendAt: Date;
@Column({
type: 'enum',
enum: MessageStatus,
default: MessageStatus.PENDING,
})
@Index() // Index for efficient querying
status: MessageStatus;
@Column({ nullable: true })
vonageMessageId?: string; // Store Vonage ID upon successful sending
@Column('text', { nullable: true })
failureReason?: string; // Store error message if sending fails
@CreateDateColumn({ type: 'timestamp with time zone' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamp with time zone' })
updatedAt: Date;
}- We use UUIDs for primary keys.
sendAtstores the exact time the message should be sent (usingtimestamp with time zoneis crucial).statustracks the message state.- Indexes on
sendAtandstatusare vital for efficient querying by the scheduler.
2.2 Configure TypeORM:
Update src/app.module.ts to configure and import TypeOrmModule.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ScheduledMessage } from './scheduling/entities/scheduled-message.entity';
// import { SchedulingModule } from './scheduling/scheduling.module';
// import { VonageModule } from './vonage/vonage.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
ScheduleModule.forRoot(),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get<string>('DB_HOST'),
port: configService.get<number>('DB_PORT'),
username: configService.get<string>('DB_USERNAME'),
password: configService.get<string>('DB_PASSWORD'),
database: configService.get<string>('DB_DATABASE'),
entities: [ScheduledMessage], // Add other entities here if needed
synchronize: false, // IMPORTANT: Set to false in production, use migrations instead
autoLoadEntities: true, // Automatically loads entities registered via forFeature()
logging: process.env.NODE_ENV === 'development', // Log SQL in development
extra: {
ssl:
process.env.NODE_ENV === 'production'
? {
rejectUnauthorized: false, // WARNING: Highly insecure for production! Requires proper certificate validation. See docs.
}
: false,
},
}),
inject: [ConfigService],
}),
// SchedulingModule, // Will be added later
// VonageModule, // Will be added later
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}- We use
TypeOrmModule.forRootAsyncto injectConfigServiceand read database credentials from.env. synchronize: falseis crucial for production. We'll use migrations.- Warning: The
rejectUnauthorized: falsesetting for SSL is insecure and should not be used in production without understanding the risks. Proper certificate validation is strongly recommended. Consult your database provider's documentation for secure SSL configuration.
2.3 Set up Migrations (Recommended):
Create a TypeORM configuration file for the CLI.
// ormconfig.ts (or ormconfig.js) in the project root
import { DataSource } from 'typeorm';
import { config } from 'dotenv';
config(); // Load .env variables
export default new DataSource({
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
entities: ['dist/**/*.entity{.ts,.js}'], // Point to compiled entities
migrations: ['dist/migrations/*{.ts,.js}'], // Point to compiled migrations
migrationsTableName: 'typeorm_migrations',
synchronize: false, // Ensure synchronize is false
logging: true,
extra: {
ssl:
process.env.NODE_ENV === 'production'
? {
rejectUnauthorized: false, // WARNING: Insecure for production! Use proper certificate validation.
}
: false,
},
});Add scripts to your package.json:
// package.json
{
// ... other package.json content
""scripts"": {
// ... other scripts
""build"": ""nest build"",
""start:dev"": ""nest start --watch"",
""start:prod"": ""node dist/main"",
""lint"": ""eslint \""{src,apps,libs,test}/**/*.ts\"" --fix"",
""test"": ""jest"",
""test:watch"": ""jest --watch"",
""test:cov"": ""jest --coverage"",
""test:debug"": ""node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand"",
""test:e2e"": ""jest --config ./test/jest-e2e.json"",
""typeorm"": ""ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --dataSource ormconfig.ts"",
""migration:generate"": ""npm run typeorm migration:generate ./src/migrations/%npm_config_name%"",
""migration:run"": ""npm run typeorm migration:run"",
""migration:revert"": ""npm run typeorm migration:revert""
},
// ... dependencies, devDependencies etc.
}- Ensure
tsconfig.jsonhasoutDirset (usually""./dist""). - You need
ts-nodeandtsconfig-pathsinstalled (npm install --save-dev ts-node tsconfig-paths).
Now you can generate and run migrations:
- Build the project:
npm run build(to create thedistfolder) - Generate migration:
npm run migration:generate --name=InitialSchema(ReplaceInitialSchemawith a descriptive name)- This creates a migration file in
src/migrations. Review it carefully.
- This creates a migration file in
- Run migration:
npm run migration:run(Applies pending migrations to the database)
3. Integrating with Vonage
We need a dedicated service to handle communication with the Vonage API.
3.1 Create Vonage Module and Service:
nest g module vonage
nest g service vonage --no-spec # --no-spec skips test file generation for brevity3.2 Implement Vonage Service:
// 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/dist/interfaces/MessageSendRequest';
import * as fs from 'fs';
import * as path from 'path';
@Injectable()
export class VonageService implements OnModuleInit {
private readonly logger = new Logger(VonageService.name);
private vonage: Vonage;
private vonageFromNumber: string;
constructor(private configService: ConfigService) {}
onModuleInit() {
const apiKey = this.configService.get<string>('VONAGE_API_KEY');
const apiSecret = this.configService.get<string>('VONAGE_API_SECRET');
const applicationId = this.configService.get<string>(
'VONAGE_APPLICATION_ID',
);
const privateKeyPath = this.configService.get<string>(
'VONAGE_PRIVATE_KEY_PATH',
);
this.vonageFromNumber = this.configService.get<string>(
'VONAGE_FROM_NUMBER',
);
if (
!apiKey ||
!apiSecret ||
!applicationId ||
!privateKeyPath ||
!this.vonageFromNumber
) {
this.logger.error('Missing Vonage credentials in environment variables.');
throw new Error('Missing Vonage credentials.');
}
const absolutePrivateKeyPath = path.resolve(process.cwd(), privateKeyPath);
if (!fs.existsSync(absolutePrivateKeyPath)) {
this.logger.error(`Private key file not found at: ${absolutePrivateKeyPath}`);
throw new Error(`Private key file not found at: ${absolutePrivateKeyPath}`);
}
try {
const privateKey = fs.readFileSync(absolutePrivateKeyPath);
this.vonage = new Vonage({
apiKey,
apiSecret,
applicationId,
privateKey,
});
this.logger.log('Vonage SDK initialized successfully.');
} catch (error) {
this.logger.error(`Failed to read private key or initialize Vonage SDK: ${error.message}`, error.stack);
throw new Error('Failed to initialize Vonage SDK.');
}
}
async sendSms(to: string, text: string): Promise<{ success: boolean; messageId?: string; error?: string }> {
if (!this.vonage) {
const errorMessage = 'Vonage SDK not initialized. Cannot send SMS.';
this.logger.error(errorMessage);
return { success: false, error: errorMessage };
}
const messageRequest: MessageSendRequest = {
message_type: 'text',
channel: 'sms',
to: to,
from: this.vonageFromNumber,
text: text,
};
try {
this.logger.log(`Attempting to send SMS to ${to}`);
const response = await this.vonage.messages.send(messageRequest);
this.logger.log(`SMS sent successfully to ${to}. Message UUID: ${response.message_uuid}`);
return { success: true, messageId: response.message_uuid };
} catch (error) {
const errorMessage = `Failed to send SMS to ${to}: ${error?.response?.data?.title || error.message}`;
this.logger.error(errorMessage, error.stack);
// Consider logging more details from error?.response?.data if available
// console.error('Vonage Error Details:', error?.response?.data);
return { success: false, error: errorMessage };
}
}
}- The service initializes the Vonage SDK in
onModuleInitusing credentials fromConfigService. - It performs essential checks for missing credentials and the private key file. Crucially, it reads the private key file content.
- The
sendSmsmethod constructs the request payload for the Messages API and handles potential errors, logging success or failure.
3.3 Update Vonage Module:
Make the VonageService available for injection.
// src/vonage/vonage.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; // Import ConfigModule if not global
import { VonageService } from './vonage.service';
@Module({
imports: [ConfigModule], // Ensure ConfigService is available
providers: [VonageService],
exports: [VonageService], // Export service for other modules to use
})
export class VonageModule {}3.4 Import VonageModule in AppModule:
// src/app.module.ts
// ... other imports
import { VonageModule } from './vonage/vonage.module'; // Import VonageModule
@Module({
imports: [
// ... other modules (ConfigModule, ScheduleModule, TypeOrmModule)
VonageModule, // Add VonageModule here
// SchedulingModule, // Will be added later
],
// ... controllers, providers
})
export class AppModule {}4. Obtaining and Configuring Vonage Credentials
Follow these steps carefully in your Vonage API Dashboard:
-
API Key and Secret:
- Log in to your Vonage API Dashboard.
- Your
API keyandAPI secretare displayed prominently on the main dashboard page (""Getting started"" section). - Copy these values into your
.envfile forVONAGE_API_KEYandVONAGE_API_SECRET.
-
Create a Vonage Application:
- Navigate to 'Applications' > 'Create a new application'.
- Give it a descriptive name (e.g., ""NestJS SMS Scheduler"").
- Click 'Generate public and private key'. Crucially, save the
private.keyfile that downloads. Place this file in your project root (or the path specified inVONAGE_PRIVATE_KEY_PATHin your.env). The public key remains with Vonage. - Enable the Messages capability.
- For Status URL and Inbound URL, you can provide webhook URLs if you need delivery receipts or inbound message handling later, but for just sending scheduled messages, they aren't strictly required. You could enter placeholder HTTPS URLs (e.g.,
https://example.com/vonage/status,https://example.com/vonage/inbound). If using ngrok for local testing of webhooks later:YOUR_NGROK_URL/webhooks/statusandYOUR_NGROK_URL/webhooks/inbound. - Click 'Generate new application'.
- Copy the generated Application ID and paste it into your
.envfile forVONAGE_APPLICATION_ID.
-
Purchase and Link a Phone Number:
- Navigate to 'Numbers' > 'Buy numbers'.
- Search for a number with SMS capability in your desired country. Note: For US numbers, you will need to comply with 10DLC regulations for Application-to-Person (A2P) traffic. This involves registering your brand and campaign, which is beyond this basic guide but essential for production US traffic.
- Purchase the number.
- Copy the purchased number (in E.164 format, e.g.,
+12015550123) and paste it into your.envfile forVONAGE_FROM_NUMBER. - Go back to 'Applications', find the application you created, and click 'Edit'.
- Under 'Link numbers', find your purchased number and click 'Link'.
-
Set Default SMS API (Important):
- Go to your main Dashboard page.
- Under 'API settings' (or similar section), find 'SMS settings'.
- Ensure that the Messages API is selected as the default API for sending SMS messages. Save changes if necessary. This ensures the SDK uses the correct backend API.
Your .env file should now be populated with valid Vonage credentials.
5. Implementing Core Functionality (Scheduling Logic)
Now, let's build the module responsible for scheduling and processing the messages.
5.1 Create Scheduling Module, Service, and Controller:
nest g module scheduling
nest g service scheduling --no-spec
nest g controller scheduling --no-spec5.2 Create Data Transfer Object (DTO) for API Requests:
We need a class to define the expected structure and validation rules for incoming schedule requests.
// src/scheduling/dto/schedule-sms.dto.ts
import {
IsNotEmpty,
IsString,
IsPhoneNumber, // From class-validator, validates E.164 format
IsDate,
MinDate,
MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer'; // Required for date transformation
export class ScheduleSmsDto {
@IsNotEmpty()
@IsPhoneNumber(null) // null region code allows global E.164 format
recipient: string;
@IsNotEmpty()
@IsString()
@MaxLength(1600) // Vonage SMS limit (though concatenation might apply)
message: string;
@IsNotEmpty()
@Type(() => Date) // Transform incoming string/number to Date object
@IsDate()
@MinDate(new Date(), { message: 'sendAt must be a future date' })
sendAt: Date;
}@IsPhoneNumber(null)validates E.164 format (e.g.,+15551234567).@Type(() => Date)and@IsDate()ensuresendAtis a valid date.@MinDate(new Date())prevents scheduling messages in the past.
5.3 Implement Scheduling Service:
This service handles saving schedules to the database and processing due messages.
// src/scheduling/scheduling.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThanOrEqual, IsNull } from 'typeorm';
import { Cron, CronExpression } from '@nestjs/schedule'; // Use Cron decorators
import {
ScheduledMessage,
MessageStatus,
} from './entities/scheduled-message.entity';
import { ScheduleSmsDto } from './dto/schedule-sms.dto';
import { VonageService } from '../vonage/vonage.service';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SchedulingService {
private readonly logger = new Logger(SchedulingService.name);
constructor(
@InjectRepository(ScheduledMessage)
private messageRepository: Repository<ScheduledMessage>,
private vonageService: VonageService,
private configService: ConfigService, // Optional: For advanced config
) {}
async scheduleSms(dto: ScheduleSmsDto): Promise<ScheduledMessage> {
const newMessage = this.messageRepository.create({
...dto,
status: MessageStatus.PENDING,
sendAt: dto.sendAt, // Ensure date object is used
});
await this.messageRepository.save(newMessage);
this.logger.log(`Scheduled SMS ID: ${newMessage.id} for ${dto.recipient} at ${dto.sendAt}`);
return newMessage;
}
// Cron job to run every minute (adjust as needed)
@Cron(CronExpression.EVERY_MINUTE)
async handleCron() {
this.logger.log('Running scheduled message check...');
await this.processDueMessages();
}
async processDueMessages(): Promise<void> {
const now = new Date();
// Find messages that are due (sendAt <= now) and still pending
const dueMessages = await this.messageRepository.find({
where: {
status: MessageStatus.PENDING,
sendAt: LessThanOrEqual(now),
},
take: 100, // Process in batches to avoid overwhelming resources
order: {
sendAt: 'ASC', // Process older messages first
},
});
if (dueMessages.length === 0) {
this.logger.log('No due messages found.');
return;
}
this.logger.log(`Found ${dueMessages.length} due messages to process.`);
for (const message of dueMessages) {
// Optional: Implement locking mechanism to prevent race conditions
// when running multiple instances (horizontal scaling).
// Example: Update status to PROCESSING before sending.
/*
const updated = await this.messageRepository.update(
{ id: message.id, status: MessageStatus.PENDING }, // Only update if still PENDING
{ status: MessageStatus.PROCESSING }
);
if (updated.affected === 0) {
// If affected is 0, another instance likely processed it between the find and update calls.
this.logger.log(`Message ID ${message.id} skipped (likely processed by another instance).`);
continue; // Skip this message
}
*/
this.logger.log(`Processing message ID: ${message.id} to ${message.recipient}`);
try {
const result = await this.vonageService.sendSms(
message.recipient,
message.message,
);
if (result.success) {
message.status = MessageStatus.SENT;
message.vonageMessageId = result.messageId;
message.failureReason = null;
this.logger.log(`Message ID: ${message.id} sent successfully. Vonage ID: ${result.messageId}`);
} else {
message.status = MessageStatus.FAILED;
message.failureReason = result.error || 'Unknown Vonage error';
this.logger.error(`Failed to send message ID: ${message.id}. Reason: ${message.failureReason}`);
// Implement retry logic here if desired (e.g., increment retry count, schedule for later)
}
} catch (error) {
message.status = MessageStatus.FAILED;
message.failureReason = error.message || 'Internal processing error';
this.logger.error(`Error processing message ID: ${message.id}: ${error.message}`, error.stack);
} finally {
// Ensure status is updated regardless of Vonage call outcome
// If using the PROCESSING status, ensure you save here.
// If not using PROCESSING, the status is already set in the try/catch blocks.
await this.messageRepository.save(message);
}
}
this.logger.log('Finished processing batch of due messages.');
}
async getMessageStatus(id: string): Promise<ScheduledMessage | null> {
return this.messageRepository.findOneBy({ id });
}
}scheduleSms: Creates and saves a newScheduledMessageentity withPENDINGstatus.handleCron: Decorated with@Cron, this method runs automatically based on theCronExpression(e.g.,EVERY_MINUTE). It callsprocessDueMessages.processDueMessages:- Queries the database for
PENDINGmessages wheresendAtis less than or equal to the current time. - Uses
LessThanOrEqualfrom TypeORM. - Processes messages in batches (
take: 100). - Iterates through due messages, calls
vonageService.sendSms. - Updates the message status to
SENTorFAILEDbased on the Vonage API response. - Logs relevant information and errors.
- Includes a commented-out section explaining a basic locking mechanism (updating status to
PROCESSING) needed to prevent race conditions if running multiple instances of the scheduler service.
- Queries the database for
getMessageStatus: Simple method to retrieve a message by ID.
5.4 Implement Scheduling Controller (API Layer):
This controller exposes the HTTP endpoint to schedule messages.
// src/scheduling/scheduling.controller.ts
import {
Controller,
Post,
Body,
UsePipes,
ValidationPipe,
HttpCode,
HttpStatus,
Logger,
Get,
Param,
NotFoundException,
} from '@nestjs/common';
import { SchedulingService } from './scheduling.service';
import { ScheduleSmsDto } from './dto/schedule-sms.dto';
import { ScheduledMessage } from './entities/scheduled-message.entity';
@Controller('schedule') // Base path for endpoints in this controller
export class SchedulingController {
private readonly logger = new Logger(SchedulingController.name);
constructor(private readonly schedulingService: SchedulingService) {}
@Post()
@HttpCode(HttpStatus.ACCEPTED) // Use 202 Accepted as scheduling is asynchronous
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) // Enable validation & transformation
async scheduleSms(
@Body() scheduleSmsDto: ScheduleSmsDto,
): Promise<{ message: string; id: string }> {
this.logger.log(`Received request to schedule SMS for ${scheduleSmsDto.recipient}`);
try {
const scheduledMessage = await this.schedulingService.scheduleSms(
scheduleSmsDto,
);
return {
message: 'SMS scheduled successfully',
id: scheduledMessage.id,
};
} catch (error) {
this.logger.error(`Failed to schedule SMS: ${error.message}`, error.stack);
// Consider re-throwing or returning a specific error response
throw error; // Re-throw to let global exception filter handle it
}
}
@Get(':id/status')
async getStatus(@Param('id') id: string): Promise<ScheduledMessage> {
this.logger.log(`Received request for status of message ID: ${id}`);
const message = await this.schedulingService.getMessageStatus(id);
if (!message) {
throw new NotFoundException(`Scheduled message with ID ${id} not found.`);
}
return message;
}
}- Uses
@Controller('schedule')to define the base route/schedule. - The
scheduleSmsmethod handlesPOST /schedulerequests. @UsePipes(new ValidationPipe(...))automatically validates the incoming request body against theScheduleSmsDto.transform: trueenables automatic conversion (like string to Date).whitelist: truestrips properties not defined in the DTO.
@HttpCode(HttpStatus.ACCEPTED)returns a 202 status code, indicating the request is accepted for processing but not yet complete.- It calls
schedulingService.scheduleSmsand returns a success response with the new schedule ID. - The
getStatusmethod handlesGET /schedule/:id/statusrequests to check a message's state.
5.5 Update Scheduling Module:
Tie everything together in the module definition.
// src/scheduling/scheduling.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SchedulingService } from './scheduling.service';
import { SchedulingController } from './scheduling.controller';
import { ScheduledMessage } from './entities/scheduled-message.entity';
import { VonageModule } from '../vonage/vonage.module'; // Import VonageModule
import { ConfigModule } from '@nestjs/config'; // Often needed here too
@Module({
imports: [
TypeOrmModule.forFeature([ScheduledMessage]), // Make repository available
VonageModule, // Import to use VonageService
ConfigModule, // Make ConfigService available
],
controllers: [SchedulingController],
providers: [SchedulingService],
})
export class SchedulingModule {}- Imports
TypeOrmModule.forFeature()to register theScheduledMessageentity and make its repository injectable within this module. - Imports
VonageModuleto gain access to theVonageService. - Imports
ConfigModuleifConfigServiceis needed directly within this module's providers (though often it's accessed via imported services likeVonageService). - Declares the controller and service.
5.6 Import SchedulingModule in AppModule:
Finally, import the SchedulingModule into the main AppModule.
// src/app.module.ts
// ... other imports
import { VonageModule } from './vonage/vonage.module';
import { SchedulingModule } from './scheduling/scheduling.module'; // Import SchedulingModule
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
ScheduleModule.forRoot(),
TypeOrmModule.forRootAsync({
// ... TypeORM config
}),
VonageModule,
SchedulingModule, // Add SchedulingModule here
],
controllers: [AppController], // Keep or remove as needed
providers: [AppService], // Keep or remove as needed
})
export class AppModule {}At this point, the core scheduling and sending logic is implemented. You should be able to start the application (npm run start:dev), send POST requests to /schedule, and see messages being processed by the cron job and sent via Vonage.
Frequently Asked Questions
How to schedule SMS messages with NestJS?
You can schedule SMS messages by sending a POST request to the /schedule endpoint of your NestJS application. This endpoint accepts a JSON payload with the recipient's phone number, the message content, and the desired send time. The message will then be stored in the database and sent at the specified time via the Vonage Messages API.
What is the Vonage Messages API used for?
The Vonage Messages API is a versatile API that enables sending and receiving messages through various channels, including SMS. In this NestJS application, it's the core component responsible for delivering the scheduled SMS messages to the recipients' phones.
Why does the application use PostgreSQL?
PostgreSQL is used as the database for this SMS scheduling application because it's a robust and reliable open-source relational database. It's well-suited for persisting scheduled jobs and ensuring that message data is stored securely and efficiently.
When should I set synchronize: true in TypeORM?
You should *never* set `synchronize: true` in a production TypeORM configuration. While convenient for development, it can lead to data loss in production. Always use migrations to manage schema changes safely and predictably.
Can I use Prisma instead of TypeORM?
Yes, you can use Prisma as an alternative ORM for this project. The article mentions Prisma as a viable option, though the provided code examples demonstrate the setup with TypeORM.
How to set up Vonage API credentials?
Obtain your API key and secret from the Vonage API Dashboard. Create a Vonage application, download the private key, enable the Messages capability, and obtain the Application ID. Purchase a Vonage phone number, link it to your application, and set the Messages API as the default for sending SMS. Don't forget to configure the 10DLC for US numbers in production.
What is the purpose of @nestjs/schedule?
The `@nestjs/schedule` module provides a declarative way to schedule tasks (cron jobs, timeouts, intervals) within a NestJS application. This project uses it to periodically check for and send SMS messages that are due.
How does the application handle message status?
The application tracks message status using an enum with states like PENDING, SENT, FAILED, and optionally PROCESSING. This allows monitoring the lifecycle of each scheduled message and implementing features like retries or error handling.
How to send an SMS message with the Vonage Node.js SDK?
The `VonageService` in the application uses the `@vonage/server-sdk` to send SMS messages. The `sendSms` method constructs a request object with recipient, sender, and message details. The `messages.send` method of the SDK then handles the actual sending process via the Vonage Messages API. This method returns a message ID or an error. You can use this ID to later query the message status via the Vonage API or Dashboard.
What is the project setup process?
First, install the NestJS CLI and create a new project using `nest new`. Install required dependencies like `@nestjs/config`, `@nestjs/schedule`, `typeorm`, `@vonage/server-sdk`. Set up environment variables in a `.env` file. Configure the TypeORM connection and generate migrations to create database tables. Initialize the Vonage and scheduling modules and services.
How to manage environment variables in NestJS?
NestJS provides the `@nestjs/config` module to manage environment variables. You can create a `.env` file to store configuration values and load them into the `ConfigService`. Never commit this file to source control.
Why use class-validator and class-transformer?
`class-validator` and `class-transformer` are used for robust request data validation. They ensure incoming data conforms to the expected format and data types, preventing common security and data integrity issues.
What are the prerequisites for this tutorial?
You'll need Node.js (LTS recommended), npm or yarn, a Vonage API account with a purchased number, access to a PostgreSQL database, basic command-line familiarity, and optionally Docker for containerization.
How does the scheduler prevent multiple instances from processing the same message?
The article provides an example of a locking mechanism using a PROCESSING status. When a message is picked up by the scheduler, its status is updated to PROCESSING before sending. If another scheduler instance attempts to process the same message, it will detect the changed status and skip the message.