Developer Guide: Building a Node.js Express App for Scheduled SMS Reminders with Vonage
This guide provides a complete walkthrough for building, deploying, and maintaining a production-ready application to schedule and send SMS reminders using Node.js, Express, and the Vonage Messages API. We'll cover everything from initial setup and core logic to database integration, error handling, security, and deployment.
Target Audience: Developers familiar with Node.js and basic web concepts looking to implement reliable scheduled messaging.
Project Overview and Goals
What We're Building: A backend service with a REST API that allows users to:
- Schedule an SMS message to be sent to a specific phone number at a future date and time.
- List upcoming scheduled messages (with pagination).
- Cancel a scheduled message.
The service will reliably check for due messages and use the Vonage API to send them.
Problem Solved: Automating the process of sending time-sensitive reminders, notifications, or alerts via SMS without manual intervention at the exact time of sending. This is useful for appointment reminders, event notifications, subscription renewals, etc.
Technologies Used:
- Node.js: The JavaScript runtime environment.
- Express: A minimal and flexible Node.js web application framework for building the API.
- Vonage Messages API: The third-party service used to send SMS messages.
node-cron
: A simple cron-like job scheduler for Node.js to periodically check for due messages.dotenv
: To manage environment variables securely.- PostgreSQL/SQLite (Recommended): A database for persistent storage of scheduled messages. We'll use Prisma as the ORM.
zod
: For robust input validation.nodemon
(Development): For automatic server restarts during development.jest
&supertest
(Testing): For automated API testing.
System Architecture:
[Placeholder: A diagram image (e.g., PNG/SVG) illustrating the system architecture should be inserted here. The diagram should show User -> API -> Scheduling Service -> Database and Vonage API -> Recipient.]
Prerequisites:
- Node.js (LTS version recommended) and npm/yarn installed.
- A Vonage API account (Sign up here for free credit).
- Your Vonage API Key and Secret (found on the Vonage API Dashboard).
- A Vonage virtual phone number capable of sending SMS (Purchase one via the Dashboard or CLI).
- Basic understanding of REST APIs, JavaScript, and asynchronous programming.
- (Optional) Docker installed if using PostgreSQL via Docker.
- (Optional) A tool like Postman or
curl
for testing the API.
1. Setting up the Project
Let's initialize the project, install dependencies, and set up the basic structure.
1.1 Initialize Project: Open your terminal and run:
# Create project directory
mkdir vonage-sms-scheduler
cd vonage-sms-scheduler
# Initialize Node.js project
npm init -y
1.2 Install Dependencies:
# Core dependencies
npm install express @vonage/server-sdk node-cron dotenv @prisma/client zod
# Database (Prisma with PostgreSQL adapter - change if using SQLite)
npm install prisma --save-dev
# Development & Testing dependencies
npm install nodemon jest supertest --save-dev
express
: Web framework.@vonage/server-sdk
: Official Vonage Node library.node-cron
: For running scheduled tasks within Node.dotenv
: Loads environment variables from a.env
file.@prisma/client
: Prisma's database client.zod
: Input validation library.prisma
: Prisma's CLI for migrations and studio.nodemon
: Automatically restarts the server on file changes during development.jest
: Testing framework.supertest
: HTTP assertion library for testing APIs.
1.3 Configure package.json
Scripts:
Open package.json
and add/modify the scripts
section:
{
// ... other configurations
""main"": ""src/server.js"",
""scripts"": {
""start"": ""node src/server.js"",
""dev"": ""nodemon src/server.js"",
""test"": ""jest"",
""prisma:migrate:dev"": ""prisma migrate dev"",
""prisma:migrate:deploy"": ""prisma migrate deploy"",
""prisma:generate"": ""prisma generate"",
""prisma:studio"": ""prisma studio""
},
// ... other configurations
}
1.4 Project Structure: Create the following directory structure:
vonage-sms-scheduler/
├── prisma/ # Prisma schema and migrations
│ └── schema.prisma
├── src/ # Source code
│ ├── config/ # Configuration files (e.g., Vonage client setup)
│ │ └── vonageClient.js
│ ├── controllers/ # Request handlers
│ │ └── scheduleController.js
│ ├── models/ # Database interaction logic (using Prisma)
│ ├── routes/ # API route definitions
│ │ └── scheduleRoutes.js
│ ├── services/ # Business logic (scheduling)
│ │ └── schedulerService.js
│ ├── utils/ # Utility functions (e.g., error handling)
│ │ └── errorHandler.js
│ └── server.js # Main application entry point
├── tests/ # Automated tests
│ └── scheduleRoutes.test.js
├── .env # Environment variables (DO NOT COMMIT)
├── .gitignore # Files/folders to ignore in Git
├── Dockerfile # (Optional) Example Docker configuration
├── jest.config.js # (Optional) Jest configuration
├── package.json
└── package-lock.json
1.5 Create .gitignore
:
Create a .gitignore
file in the root directory:
# .gitignore
node_modules/
.env
dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Prisma
prisma/dev.db # If using SQLite
prisma/dev.db-journal
# Jest cache
coverage/
.jest-cache/
1.6 Create .env
File:
Create a .env
file in the root directory and add your Vonage credentials and number. Never commit this file to version control.
# .env
# Vonage API Credentials (Get from Vonage Dashboard)
VONAGE_API_KEY=YOUR_API_KEY
VONAGE_API_SECRET=YOUR_API_SECRET
# Optional, needed for JWT authentication or features requiring an application context (e.g., webhooks, voice), not strictly for basic SMS via API Key/Secret
# VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
# Optional, path to private key if using JWT auth
# VONAGE_PRIVATE_KEY_PATH=./private.key
# Vonage Number (Must be SMS-enabled and purchased in your account)
VONAGE_FROM_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER
# Database Connection (Example for PostgreSQL)
# Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
DATABASE_URL=""postgresql://user:password@localhost:5432/sms_scheduler?schema=public""
# Server Configuration
PORT=3000
# Scheduler Configuration
# Run every minute by default (See Warning Below)
SCHEDULER_CRON_PATTERN=""*/1 * * * *""
# Logging Level (optional, for structured logging)
# LOG_LEVEL=info
- Obtaining Credentials:
VONAGE_API_KEY
,VONAGE_API_SECRET
: Found at the top of the Vonage API Dashboard.VONAGE_FROM_NUMBER
: Go to Numbers > Your Numbers in the dashboard. Ensure the number has SMS capability. Use the E.164 format (e.g., 12015550123).DATABASE_URL
: Adjust this based on your chosen database (PostgreSQL, MySQL, SQLite). For local PostgreSQL with Docker, you might usepostgresql://postgres:password@localhost:5432/sms_scheduler
. For SQLite, it would befile:./prisma/dev.db
.- Security Warning: The example
DATABASE_URL
uses defaultuser:password
. Never use default or easily guessable credentials in production. Use strong, unique passwords and consider secrets management solutions.
- Scheduler Frequency Warning: The default
SCHEDULER_CRON_PATTERN
runs every minute. While useful for testing, this can be resource-intensive and potentially costly (e.g., database queries, API calls if many messages are due). Consider less frequent intervals (e.g.,*/5 * * * *
for every 5 minutes) for production unless per-minute granularity is essential.
1.7 Initialize Prisma: Run the Prisma init command. If you haven't already chosen a database type, it will prompt you. We'll use PostgreSQL here.
npx prisma init --datasource-provider postgresql
This creates the prisma/schema.prisma
file and updates .env
with a default DATABASE_URL
(which you should have already customized).
2. Creating a Database Schema and Data Layer
We need a way to store the scheduled messages persistently.
2.1 Define Prisma Schema:
Open prisma/schema.prisma
and define the model for our scheduled messages:
// prisma/schema.prisma
generator client {
provider = ""prisma-client-js""
}
datasource db {
provider = ""postgresql"" // Or ""sqlite"", ""mysql"", etc.
url = env(""DATABASE_URL"")
}
model ScheduledMessage {
id String @id @default(cuid()) // Unique identifier
recipientNumber String // E.164 format phone number
message String // The SMS message content
sendAt DateTime // The exact UTC time to send the message
status String @default(""pending"") // 'pending', 'sent', 'failed', 'processing'
vonageMessageId String? // Store Vonage message ID on success
failureReason String? // Store error message on failure
retryCount Int @default(0) // For implementing retries
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, sendAt]) // Index for efficient querying by the scheduler
}
id
: Primary key.recipientNumber
: Target phone number.message
: SMS content.sendAt
: Crucial field, stored in UTC.status
: Tracks the message state ('pending', 'sent', 'failed', potentially 'processing' for concurrency).vonageMessageId
: Useful for tracking successful sends.failureReason
: For debugging failed sends.retryCount
: Added for potential retry logic.@@index
: Optimizes queries for finding pending messages due to be sent.
2.2 Apply Database Migrations: Create the table in your database using Prisma Migrate. Make sure your database server (e.g., PostgreSQL) is running.
# This creates the migration SQL file and applies it to the database
npx prisma migrate dev --name init_scheduled_messages
Prisma will generate SQL based on your schema and apply it. For production, you'll use npx prisma migrate deploy
.
2.3 Generate Prisma Client: Generate the type-safe database client:
npx prisma generate
Now you can import and use PrismaClient
in your code.
2.4 (Optional) Explore with Prisma Studio: You can view and manipulate your database data easily:
npx prisma studio
3. Implementing Core Functionality (Scheduling Service)
This service will periodically check the database for messages that need to be sent.
3.1 Configure Vonage Client:
Create src/config/vonageClient.js
:
// src/config/vonageClient.js
import { Vonage } from '@vonage/server-sdk';
import { Auth } from '@vonage/auth';
import dotenv from 'dotenv';
dotenv.config();
const credentials = new Auth({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET,
// Include these if using Application authentication (e.g., for webhooks, not strictly needed for basic SMS)
// applicationId: process.env.VONAGE_APPLICATION_ID,
// privateKey: process.env.VONAGE_PRIVATE_KEY_PATH,
});
const vonage = new Vonage(credentials);
export default vonage;
- Why this setup? Encapsulates the Vonage client initialization, making it reusable and easy to manage credentials. Uses the recommended
Auth
class.
3.2 Create the Scheduler Service:
Create src/services/schedulerService.js
:
// src/services/schedulerService.js
import cron from 'node-cron';
import { PrismaClient } from '@prisma/client';
import vonage from '../config/vonageClient.js';
import dotenv from 'dotenv';
dotenv.config();
const prisma = new PrismaClient();
// Default to every minute. WARNING: Running every minute can be resource-intensive. Consider '*/5 * * * *' or less frequent for production if per-minute precision isn't critical.
const CRON_PATTERN = process.env.SCHEDULER_CRON_PATTERN || '*/1 * * * *';
const VONAGE_FROM_NUMBER = process.env.VONAGE_FROM_NUMBER;
if (!VONAGE_FROM_NUMBER) {
console.error('Error: VONAGE_FROM_NUMBER environment variable is not set.');
process.exit(1); // Exit if critical config is missing
}
async function findAndSendDueMessages() {
console.log(`[${new Date().toISOString()}] Scheduler running...`);
const now = new Date();
try {
const messagesToSend = await prisma.scheduledMessage.findMany({
where: {
status: 'pending',
sendAt: {
lte: now, // Find messages where sendAt is now or in the past
},
},
take: 100, // Limit batch size to avoid overwhelming resources
orderBy: {
sendAt: 'asc', // Process oldest first
}
});
if (messagesToSend.length === 0) {
console.log(`[${new Date().toISOString()}] No messages due.`);
return;
}
console.log(`[${new Date().toISOString()}] Found ${messagesToSend.length} messages to send.`);
for (const msg of messagesToSend) {
try {
// Optional: Implement 'processing' status for better concurrency control if needed
// await prisma.scheduledMessage.update({ where: { id: msg.id }, data: { status: 'processing' } });
console.log(`[${new Date().toISOString()}] Attempting to send message ID: ${msg.id} to ${msg.recipientNumber}`);
const responseData = await vonage.sms.send({
to: msg.recipientNumber,
from: VONAGE_FROM_NUMBER,
text: msg.message,
});
// Vonage API v3 structure check (success usually includes message_uuid)
if (responseData.message_uuid) {
console.log(`[${new Date().toISOString()}] Message ${msg.id} sent successfully. Vonage ID: ${responseData.message_uuid}`);
await prisma.scheduledMessage.update({
where: { id: msg.id },
data: {
status: 'sent',
vonageMessageId: responseData.message_uuid,
failureReason: null, // Clear any previous failure reason
},
});
} else {
// Handle unexpected responses or potential errors where message_uuid is missing
const errorText = responseData.messages?.[0]?.['error-text'] || 'Unknown error or unexpected response structure';
const status = responseData.messages?.[0]?.status || 'N/A';
const failureReason = `Vonage API Error: ${errorText}. Status: ${status}. Response: ${JSON.stringify(responseData)}`;
console.error(`[${new Date().toISOString()}] Failed to send message ${msg.id} (message_uuid missing): ${failureReason}`);
await prisma.scheduledMessage.update({
where: { id: msg.id },
data: {
status: 'failed',
failureReason: failureReason.substring(0, 500), // Limit length
// Optional: Implement retry logic here based on error type/status
// retryCount: { increment: 1 }
},
});
}
} catch (error) {
// Handle errors during the send process (network, SDK issues)
const failureReason = `Error sending SMS for ID ${msg.id}: ${error.message || error.toString()}`;
console.error(`[${new Date().toISOString()}] ${failureReason}`, error.stack); // Log stack for debugging
await prisma.scheduledMessage.update({
where: { id: msg.id },
data: {
status: 'failed',
failureReason: failureReason.substring(0, 500),
// Optional: Increment retry count
// retryCount: { increment: 1 }
},
});
}
}
} catch (error) {
// Handle errors querying the database
console.error(`[${new Date().toISOString()}] Error querying database: ${error.message || error.toString()}`, error.stack);
}
}
function startScheduler() {
console.log(`[${new Date().toISOString()}] Initializing scheduler with pattern: ${CRON_PATTERN}`);
// Validate cron pattern before scheduling
if (!cron.validate(CRON_PATTERN)) {
console.error(`[${new Date().toISOString()}] Invalid CRON pattern: ""${CRON_PATTERN}"". Scheduler not started.`);
return; // Don't schedule if pattern is invalid
}
// Schedule the task
cron.schedule(CRON_PATTERN, findAndSendDueMessages, {
scheduled: true,
timezone: ""UTC"" // IMPORTANT: Run cron in UTC to match DB times
});
console.log(`[${new Date().toISOString()}] Scheduler started successfully.`);
// Optional: Run once immediately on start-up for faster processing of any backlog
// console.log(`[${new Date().toISOString()}] Running initial check on startup...`);
// findAndSendDueMessages();
}
export { startScheduler, findAndSendDueMessages }; // Export findAndSend for potential manual trigger/testing
- Why
node-cron
? Simple and effective for running tasks at specific intervals within the Node.js process. For highly critical, distributed systems, a dedicated external task queue/scheduler might be better, butnode-cron
is excellent for many use cases. - Why Query Logic?
status: 'pending'
andsendAt: { lte: now }
efficiently finds only the messages that are due and haven't been processed.take: 100
prevents loading too many messages at once.orderBy
ensures older messages are processed first. - Why UTC? Storing dates and running the scheduler in UTC avoids time zone ambiguity and conversion issues. The client creating the schedule is responsible for converting their local time to UTC before sending it to the API.
- Error Handling: Each message send is wrapped in
try...catch
. Failures update the status to 'failed' with a reason. The logic now focuses onmessage_uuid
for success and logs detailed errors otherwise. Database query errors are also caught. - Vonage Response: The code primarily checks for
message_uuid
which indicates success in the current Vonage Messages API response structure. If it's missing, the response is logged, and the message is marked as failed.
4. Building the API Layer (Express)
Let's create the endpoints to manage scheduled messages.
4.1 Basic Express Server Setup:
Create src/server.js
:
// src/server.js
import express from 'express';
import dotenv from 'dotenv';
import helmet from 'helmet'; // Security headers
import rateLimit from 'express-rate-limit'; // Basic rate limiting
import scheduleRoutes from './routes/scheduleRoutes.js';
import { startScheduler } from './services/schedulerService.js';
import { genericErrorHandler } from './utils/errorHandler.js';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
// --- Middlewares ---
// Security Headers
app.use(helmet());
// Parse JSON bodies & URL-encoded bodies
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Basic Rate Limiting (apply before API routes)
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
message: 'Too many requests from this IP, please try again after 15 minutes',
});
app.use('/api', apiLimiter); // Apply limiter to all /api routes
// --- Routes ---
// Basic Health Check
app.get('/health', (req, res) => {
res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
});
// API Routes
app.use('/api/schedules', scheduleRoutes);
// --- Error Handling ---
// Global Error Handler - Must be the LAST middleware
app.use(genericErrorHandler);
// --- Server Startup ---
// Start the scheduler
startScheduler();
// Start the server
const server = app.listen(PORT, () => {
console.log(`[${new Date().toISOString()}] Server running on http://localhost:${PORT}`);
});
// Graceful Shutdown (Optional but Recommended)
process.on('SIGTERM', () => {
console.log('SIGTERM signal received: closing HTTP server');
server.close(() => {
console.log('HTTP server closed');
// Close database connection if needed (Prisma handles this reasonably well)
process.exit(0);
});
});
export default app; // Export app for testing
4.2 Create API Routes:
Create src/routes/scheduleRoutes.js
:
// src/routes/scheduleRoutes.js
import express from 'express';
import {
createSchedule,
getSchedules,
getScheduleById,
cancelSchedule,
} from '../controllers/scheduleController.js';
const router = express.Router();
// POST /api/schedules - Create a new scheduled message
router.post('/', createSchedule);
// GET /api/schedules - Get a list of scheduled messages (with pagination & filtering)
router.get('/', getSchedules);
// GET /api/schedules/:id - Get a specific scheduled message
router.get('/:id', getScheduleById);
// DELETE /api/schedules/:id - Cancel a pending scheduled message
router.delete('/:id', cancelSchedule);
export default router;
4.3 Create Controller Logic:
Create src/controllers/scheduleController.js
:
// src/controllers/scheduleController.js
import { PrismaClient } from '@prisma/client';
import { z } from 'zod'; // For input validation
const prisma = new PrismaClient();
// Zod Schema for input validation when creating a schedule
const createScheduleSchema = z.object({
recipientNumber: z.string().trim().regex(/^\+?[1-9]\d{1,14}$/, { message: ""Invalid E.164 phone number format (e.g., +12125551234)"" }), // E.164 format validation
message: z.string().min(1, { message: ""Message cannot be empty"" }).max(1600, { message: ""Message exceeds maximum length (1600 chars)"" }), // Generous limit for multi-part SMS
sendAt: z.string().datetime({ offset: true, message: ""Invalid ISO 8601 datetime string (YYYY-MM-DDTHH:mm:ss.sssZ)"" }) // Expect ISO 8601 UTC string e.g., ""2025-12-31T10:00:00.000Z""
.refine(dateStr => new Date(dateStr) > new Date(Date.now() + 10000), { message: ""sendAt must be at least 10 seconds in the future"" }), // Add buffer
});
// Controller function to create a schedule
export const createSchedule = async (req, res, next) => {
try {
// 1. Validate Input
const validationResult = createScheduleSchema.safeParse(req.body);
if (!validationResult.success) {
return res.status(400).json({
message: ""Validation failed"",
errors: validationResult.error.format(),
});
}
const { recipientNumber, message, sendAt } = validationResult.data;
// 2. Convert sendAt to Date object (Prisma expects Date)
const sendAtDate = new Date(sendAt); // Zod ensures it's a valid ISO string
// 3. Create in Database
const newSchedule = await prisma.scheduledMessage.create({
data: {
recipientNumber,
message,
sendAt: sendAtDate, // Store as Date object (Prisma handles UTC conversion if necessary)
status: 'pending',
},
});
// 4. Respond
res.status(201).json({
message: 'SMS scheduled successfully',
schedule: newSchedule,
});
} catch (error) {
console.error(""Error in createSchedule:"", error);
next(error); // Pass error to the generic error handler
}
};
// Controller function to get schedules with basic pagination/filtering
export const getSchedules = async (req, res, next) => {
try {
const { status, limit = 50, offset = 0 } = req.query; // Default limit 50, offset 0
const queryLimit = parseInt(limit, 10);
const queryOffset = parseInt(offset, 10);
// Validate limit/offset
if (isNaN(queryLimit) || queryLimit <= 0 || queryLimit > 200) { // Add a reasonable max limit
return res.status(400).json({ message: 'Invalid or excessive limit parameter. Must be between 1 and 200.' });
}
if (isNaN(queryOffset) || queryOffset < 0) {
return res.status(400).json({ message: 'Invalid offset parameter. Must be 0 or greater.' });
}
// Build filter conditions
const whereClause = {};
if (status && ['pending'_ 'sent'_ 'failed'_ 'processing'].includes(status)) {
whereClause.status = status;
}
// Fetch data and total count for pagination info using a transaction
const [schedules_ totalCount] = await prisma.$transaction([
prisma.scheduledMessage.findMany({
where: whereClause_
orderBy: {
createdAt: 'desc'_ // Show newest first by default
}_
take: queryLimit_
skip: queryOffset_
})_
prisma.scheduledMessage.count({ where: whereClause }) // Get total count matching filter
]);
// Respond with data and pagination metadata
res.status(200).json({
data: schedules_
pagination: {
total: totalCount_
limit: queryLimit_
offset: queryOffset_
count: schedules.length // Count of items in the current response page
}
});
} catch (error) {
console.error(""Error in getSchedules:""_ error);
next(error);
}
};
// Controller function to get a single schedule by ID
export const getScheduleById = async (req_ res_ next) => {
try {
const { id } = req.params;
// Basic validation for ID format if needed (e.g., CUID)
if (!id || typeof id !== 'string' || id.length < 10) { // Simple check
return res.status(400).json({ message: 'Invalid ID format' });
}
const schedule = await prisma.scheduledMessage.findUnique({
where: { id: id }_
});
if (!schedule) {
return res.status(404).json({ message: 'Scheduled message not found' });
}
res.status(200).json(schedule);
} catch (error) {
console.error(""Error in getScheduleById:""_ error);
// Handle specific Prisma errors if needed (e.g._ invalid ID format if Prisma throws)
next(error);
}
};
// Controller function to cancel a schedule
export const cancelSchedule = async (req_ res_ next) => {
try {
const { id } = req.params;
if (!id || typeof id !== 'string' || id.length < 10) {
return res.status(400).json({ message: 'Invalid ID format' });
}
// Use a transaction to find and delete atomically
const result = await prisma.$transaction(async (tx) => {
// Find the message first to ensure it exists and is pending
const schedule = await tx.scheduledMessage.findUnique({
where: { id: id },
select: { status: true } // Only select status needed for check
});
if (!schedule) {
// Throw error to trigger rollback and custom response below
throw new Error('NotFound');
}
if (schedule.status !== 'pending') {
// Throw error to trigger rollback and custom response below
throw new Error(`Cannot cancel message with status: ${schedule.status}`);
}
// Delete the message if pending
await tx.scheduledMessage.delete({
where: { id: id },
});
return { success: true };
});
if (result.success) {
res.status(200).json({ message: 'Scheduled message cancelled successfully' });
}
// Note: If transaction fails due to thrown errors, the catch block handles it.
} catch (error) {
// Handle specific errors from the transaction
if (error.message === 'NotFound') {
return res.status(404).json({ message: 'Scheduled message not found' });
}
if (error.message.startsWith('Cannot cancel message')) {
return res.status(400).json({ message: error.message });
}
// Handle potential Prisma error if deletion fails concurrently (rare with transaction)
if (error.code === 'P2025') { // Record to delete does not exist (should be caught by NotFound)
return res.status(404).json({ message: 'Scheduled message not found or already processed/cancelled' });
}
console.error(""Error in cancelSchedule:"", error);
next(error); // Pass other errors to generic handler
}
};
- Input Validation (Zod): Using
zod
provides robust validation for the request body (recipientNumber
format,message
length,sendAt
format and future date). This is crucial for security and data integrity. Added.trim()
to phone number and required ISO 8601 with offset (which implies UTC 'Z'). Added small buffer tosendAt
check. - Error Handling:
try...catch
blocks wrap async operations. Errors are passed to thenext
function, routing them to the global error handler. Specific errors (like validation, not found, bad status for cancel) are handled directly with appropriate status codes. - Database Interaction: Uses the generated
prisma
client for CRUD operations.getSchedules
now includes pagination logic and returns metadata.cancelSchedule
uses a transaction for atomicity. - Status Codes: Uses appropriate HTTP status codes (201 Created, 200 OK, 400 Bad Request, 404 Not Found).
- Pagination:
getSchedules
implements limit/offset pagination with validation and returns results along with total count information. - Cancellation Logic: Only allows deleting messages that are still in 'pending' status, enforced within a transaction.
5. Implementing Error Handling and Logging
Robust error handling and clear logging are vital for production.
5.1 Create Generic Error Handler:
Create src/utils/errorHandler.js
:
// src/utils/errorHandler.js
export const genericErrorHandler = (err, req, res, next) => {
// Log the error using console.error or a dedicated logger
console.error(`[${new Date().toISOString()}] Unhandled Error: ${err.message}`);
console.error(err.stack); // Log the full stack trace for debugging
// If the response headers have already been sent, delegate to the default Express error handler
if (res.headersSent) {
return next(err);
}
// Determine status code - use error's status code if available, otherwise default to 500
const statusCode = typeof err.statusCode === 'number' ? err.statusCode : 500;
// Prepare response body - avoid leaking stack trace in production
const responseBody = {
message: statusCode === 500 ? 'Internal Server Error' : err.message,
// Optionally include more detail like error code or type in non-production
...(process.env.NODE_ENV !== 'production' && { errorType: err.name, stack: err.stack }),
};
res.status(statusCode).json(responseBody);
};
- Why? Catches any unhandled errors passed via
next(error)
or thrown synchronously/asynchronously in middleware/controllers. Provides a consistent error response format. Logs detailed errors server-side. Avoids sending stack traces in production responses.
5.2 Logging:
- Current: We are using
console.log
andconsole.error
. This is acceptable for simple cases or development. - Production Recommendation: Use a dedicated logging library like
pino
(very performant) orwinston
. These offer:- Log levels (info, warn, error, debug, etc.)
- Structured logging (JSON format is common) for easier parsing by log management systems.
- Configurable output destinations (console, file, external services).
- Log rotation and archiving.