Build a Robust SMS Scheduler with Node.js, Express, and Vonage
This guide details how to build a robust SMS scheduling and reminder application using Node.js, Express, and the Vonage Messages API. You'll learn how to accept scheduling requests via an API, manage job state, use a scheduler to send messages at the correct time via Vonage, and handle potential issues like errors and basic retries.
This application addresses the common need to send notifications, reminders, or alerts at specific future times without manual intervention. We'll use Node.js for its event-driven model, Express for the API, node-cron
for simple scheduling (while discussing more robust production alternatives), and the Vonage Messages API for SMS delivery.
> Important Note on Production Readiness: While this guide covers many concepts essential for a production system (error handling, security, database considerations, monitoring), the core code example uses an in-memory data store and the basic node-cron
scheduler for simplicity. A true production deployment requires replacing the in-memory store with a persistent database and potentially using a more advanced job queue system (like BullMQ or Agenda), as discussed in detail within the guide.
System Architecture:
(Note: The original diagram block (Mermaid) has been removed as it is non-standard Markdown.)
Prerequisites:
- A Vonage API account (Sign up here if you don't have one).
- Node.js and npm (or yarn) installed locally. (Download Node.js).
ngrok
installed for testing webhooks locally during development. (Download ngrok).ngrok
is suitable only for development/testing and must not be used for production webhooks. A stable, publicly accessible HTTPS endpoint is required for production.- Basic familiarity with JavaScript, Node.js, and REST APIs.
- (Optional but Recommended) Vonage CLI installed:
npm install -g @vonage/cli
Final Outcome:
By the end of this guide, you will have a functional Node.js application that:
- Exposes an API endpoint (
/schedule
) to accept SMS scheduling requests. - Stores scheduled message details (using an in-memory store in the example code, with guidance on persistent storage).
- Uses a basic scheduler (
node-cron
) to send messages via Vonage, with discussion on production alternatives. - Includes basic configuration, error handling, logging, and security considerations.
- Provides conceptual handling for inbound SMS and delivery status webhooks.
GitHub Repository:
A complete working example of the code in this guide can be found here: https://github.com/vonage-community/node-sms-scheduler-guide
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for your project, then navigate into it.
mkdir node-sms-scheduler cd node-sms-scheduler
-
Initialize Node.js Project: This creates a
package.json
file to manage dependencies and project metadata.npm init -y
-
Install Dependencies: We need Express for the web server, the Vonage SDK for sending SMS,
node-cron
for scheduling,dotenv
for managing environment variables,uuid
for generating unique job IDs, andhelmet
/express-rate-limit
for basic security.npm install express @vonage/server-sdk node-cron dotenv uuid helmet express-rate-limit express-validator # Optional for concrete retry example: # npm install async-retry
express
: Web framework for Node.js.@vonage/server-sdk
: Official Vonage SDK for Node.js.node-cron
: Task scheduler based on cron syntax. (Simple, but see limitations in Section 12).dotenv
: Loads environment variables from a.env
file.uuid
: Generates unique identifiers.helmet
: Helps secure Express apps by setting various HTTP headers.express-rate-limit
: Basic rate limiting middleware.express-validator
: Middleware for request data validation.async-retry
: (Optional) Useful library for implementing retry logic.
-
Create Project Structure: Set up a basic directory structure.
mkdir src touch src/server.js touch .env touch .gitignore
src/
: Contains our application source code.src/server.js
: The main entry point for our Express application and scheduler..env
: Stores sensitive credentials and configuration. Never commit this file to version control..gitignore
: Specifies files Git should ignore.
-
Configure
.gitignore
: Add the following lines to your.gitignore
file:# Dependencies node_modules/ # Environment variables .env *.env.* !.env.example # Keys *.key # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Optional editor directories .idea .vscode *.swp
-
Set up Environment Variables (
.env
): Open the.env
file and add the following placeholders. We will fill these in during the Vonage configuration step.# 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 Number VONAGE_FROM_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # Application Settings PORT=3000 SCHEDULER_INTERVAL_SECONDS=60 # How often the scheduler checks for jobs # Webhook Security (Example - Replace with actual secret or use signature verification) # INBOUND_WEBHOOK_SECRET=some-unguessable-string # Database (Required for production - Example for PostgreSQL) # DATABASE_URL=""postgresql://user:password@host:port/database""
- Purpose: Using environment variables keeps sensitive data out of your codebase and allows for different configurations across environments.
dotenv
makes it easy to load these during development.
- Purpose: Using environment variables keeps sensitive data out of your codebase and allows for different configurations across environments.
2. Vonage Configuration
To send SMS messages, we need to configure a Vonage application and link a virtual number.
- Log in to Vonage Dashboard: Go to the Vonage API Dashboard.
- Get API Key and Secret: Find these on the dashboard homepage and add them to your
.env
file. - Create a Vonage Application:
- Navigate to ""Applications"" > ""Create a new application"".
- Name it (e.g., ""Node SMS Scheduler"").
- Click ""Generate public and private key"". Save the downloaded
private.key
file securely in your project root (or specified path). UpdateVONAGE_PRIVATE_KEY_PATH
in.env
. - Enable the ""Messages"" capability.
- For ""Inbound URL"" and ""Status URL"", you need a public URL. For development only, use
ngrok
:- Open a new terminal:
ngrok http 3000
(replace 3000 if yourPORT
is different). - Copy the HTTPS ""Forwarding"" URL (e.g.,
https://randomstring.ngrok.io
). - Enter these URLs in Vonage (replace
YOUR_NGROK_URL
):- Inbound URL:
YOUR_NGROK_URL/webhooks/inbound
- Status URL:
YOUR_NGROK_URL/webhooks/status
- Inbound URL:
- WARNING:
ngrok
URLs are temporary and not for production. Production requires a stable public HTTPS endpoint.
- Open a new terminal:
- Click ""Create application"".
- Copy the ""Application ID"" and add it to
.env
.
- Buy and Link a Vonage Number:
- Go to ""Numbers"" > ""Buy numbers"", find and buy an SMS-capable number.
- Go to ""Numbers"" > ""Your numbers"", find the number, click ""Link"" (or gear icon), and link it to your ""Node SMS Scheduler"" application.
- Copy the number (with country code) and add it to
.env
asVONAGE_FROM_NUMBER
.
- Ensure Messages API is Default:
- Go to API Settings (
https://dashboard.nexmo.com/settings
). - Under ""SMS Settings"", set ""Default SMS Setting"" to ""Messages API"". Save changes.
- Go to API Settings (
(Optional) Using Vonage CLI: You can perform steps 3 & 4 via CLI (use your ngrok
URL when prompted for webhooks).
3. Implementing Core Functionality: Scheduling & Sending
Let's write the code for the Express server, Vonage setup, scheduling logic, and API endpoint.
src/server.js
:
// src/server.js
require('dotenv').config();
const express = require('express');
const { Vonage } = require('@vonage/server-sdk');
const { Auth } = require('@vonage/auth');
const cron = require('node-cron');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');
const path = require('path'); // For resolving key path
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const { body, validationResult } = require('express-validator');
const app = express();
// --- Middleware ---
app.use(helmet()); // Basic security headers
app.use(express.json()); // Parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies
// --- Configuration ---
const PORT = process.env.PORT || 3000;
const SCHEDULER_INTERVAL_SECONDS = parseInt(process.env.SCHEDULER_INTERVAL_SECONDS || '60', 10);
// --- Vonage Initialization ---
let vonage;
try {
const privateKeyPath = path.resolve(process.env.VONAGE_PRIVATE_KEY_PATH || './private.key');
if (!fs.existsSync(privateKeyPath)) {
throw new Error(`Private key file not found at: ${privateKeyPath}`);
}
const privateKey = fs.readFileSync(privateKeyPath);
const credentials = new Auth({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET,
applicationId: process.env.VONAGE_APPLICATION_ID,
privateKey: privateKey
});
vonage = new Vonage(credentials);
console.log('Vonage SDK initialized successfully.');
} catch (error) {
console.error('FATAL: Failed to initialize Vonage SDK:', error.message);
console.error('Check .env configuration and private key path/permissions.');
process.exit(1);
}
// --- In-Memory Job Store (DEMONSTRATION ONLY) ---
// WARNING: THIS IS NOT PRODUCTION-READY. Data is lost on application restart.
// Replace this with a persistent database (PostgreSQL, MongoDB, etc.) for any real use case.
const scheduledJobs = new Map(); // Map<jobId_ JobDetails>
// interface JobDetails { jobId, to, from, text, sendAt: Date, status: 'pending' | 'processing' | 'sent' | 'failed', messageUuid?: string, error?: string, createdAt: Date }
// --- Rate Limiter ---
const scheduleLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many schedule requests created from this IP, please try again after 15 minutes'
});
// --- API Endpoints ---
/**
* @route POST /schedule
* @description Schedules an SMS message.
*/
app.post('/schedule',
scheduleLimiter, // Apply rate limiting
// Input Validation Middleware
body('to').isMobilePhone('any', { strictMode: false }).withMessage('Valid destination phone number (to) is required'),
body('text').isString().trim().notEmpty().withMessage('Message text cannot be empty'),
body('sendAt').isISO8601().withMessage('sendAt must be a valid ISO 8601 date-time string')
.custom((value) => {
if (new Date(value) <= new Date()) {
throw new Error('sendAt date must be in the future.');
}
return true;
})_
(req_ res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { to, text, sendAt } = req.body;
const sendAtDate = new Date(sendAt);
const jobId = uuidv4();
const jobDetails = {
jobId,
to,
from: process.env.VONAGE_FROM_NUMBER,
text,
sendAt: sendAtDate,
status: 'pending',
createdAt: new Date()
};
// === PERSISTENCE LOGIC ===
// !! REPLACE THIS with database insertion !!
scheduledJobs.set(jobId, jobDetails);
// Example DB call: await db.insertJob(jobDetails);
// =========================
console.log(`[API] Job scheduled: ${jobId} for ${sendAtDate.toISOString()} to ${to}`);
res.status(202).json({
jobId: jobId,
status: 'scheduled',
sendAt: sendAtDate.toISOString()
});
}
);
// --- Webhook Endpoints ---
// IMPORTANT: Secure these endpoints in production using signature verification (see Section 8)
/**
* @route POST /webhooks/status
* @description Receives delivery status updates from Vonage.
*/
app.post('/webhooks/status', (req, res) => {
// TODO: Implement Vonage Webhook Signature Verification here for security!
console.log('[Webhook Status] Received:', JSON.stringify(req.body, null, 2));
const { message_uuid, status, err_code, price, timestamp } = req.body;
// === PERSISTENCE LOGIC ===
// Find the job associated with this message_uuid and update its status in the DATABASE.
// This requires storing message_uuid when the message is successfully sent.
let associatedJobId = null;
for (const [jobId, job] of scheduledJobs.entries()) { // !! Replace with DB lookup !!
if (job.messageUuid === message_uuid) {
associatedJobId = jobId;
console.log(`[Webhook Status] Correlated message_uuid ${message_uuid} to job ${jobId}`);
// Example DB update: await db.updateJobStatusByMessageUUID(message_uuid, status, err_code);
// Update the in-memory map (for demo purposes only)
const currentJob = scheduledJobs.get(jobId);
if(currentJob) {
currentJob.status = status === 'delivered' ? 'delivered' : (status === 'failed' || status === 'rejected' ? 'failed' : status);
if (err_code) currentJob.error = `Vonage Error: ${err_code}`;
}
break;
}
}
if (!associatedJobId) {
console.warn(`[Webhook Status] Received status for unknown message_uuid: ${message_uuid}`);
}
// =========================
res.status(200).send('OK'); // Always respond 200 OK to Vonage quickly
});
/**
* @route POST /webhooks/inbound
* @description Receives inbound SMS messages sent to your Vonage number.
*/
app.post('/webhooks/inbound', (req, res) => {
// TODO: Implement Vonage Webhook Signature Verification here for security!
console.log('[Webhook Inbound] Received:', JSON.stringify(req.body, null, 2));
const { from, to, text, timestamp, message_uuid } = req.body;
// Process inbound message (e.g., handle STOP/HELP, route replies)
// Example: Check if text.trim().toUpperCase() === 'STOP' and update opt-out status in DB.
res.status(200).send('OK'); // Always respond 200 OK
});
// --- Health Check Endpoint ---
app.get('/health', (req, res) => {
// TODO: Add checks for database connectivity in a real deployment
res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
});
// --- Scheduler Logic ---
console.log(`[Scheduler] Initializing cron job to run every ${SCHEDULER_INTERVAL_SECONDS} seconds.`);
console.warn(`[Scheduler] Using node-cron. Limitations apply (see docs/Section 12). Consider robust queues (BullMQ, Agenda) for production.`);
const schedulerTask = cron.schedule(`*/${SCHEDULER_INTERVAL_SECONDS} * * * * *`, async () => {
const now = new Date();
console.log(`[Scheduler Tick - ${now.toISOString()}] Checking for pending jobs...`);
// === PERSISTENCE LOGIC ===
// !! REPLACE iteration with a database query !!
// Example DB Query: const jobsToSend = await db.findPendingJobsDue(now, 10); // Fetch N jobs due now
// Use SELECT ... FOR UPDATE SKIP LOCKED on PostgreSQL to handle concurrency if scaling horizontally.
const jobsToSend = [];
for (const job of scheduledJobs.values()) {
if (job.status === 'pending' && job.sendAt <= now) {
jobsToSend.push(job);
}
}
// =========================
if (jobsToSend.length === 0) {
console.log(`[Scheduler Tick] No jobs due.`);
return;
}
console.log(`[Scheduler Tick] Found ${jobsToSend.length} job(s) to process.`);
for (const job of jobsToSend) {
console.log(`[Scheduler] Processing job: ${job.jobId}`);
// === PERSISTENCE LOGIC ===
// Mark job as 'processing' in the DATABASE to prevent reprocessing by this or other workers.
// Example DB: await db.updateJobStatus(job.jobId_ 'processing');
// CAVEAT: If the process crashes *after* this update but *before* sending_ the job might get stuck.
// Database transactions or robust job queues handle this better.
job.status = 'processing'; // Update in-memory state (demo only)
// =========================
try {
// TODO: Implement proper retry logic here (see Section 6)
const resp = await vonage.messages.send({
message_type: 'text'_
to: job.to_
from: job.from_
channel: 'sms'_
text: job.text_
client_ref: job.jobId // Useful for correlating status webhooks if message_uuid lookup fails
});
console.log(`[Scheduler] Message sent via Vonage for job ${job.jobId}. Message UUID: ${resp.message_uuid}`);
// === PERSISTENCE LOGIC ===
// Update job status to 'sent' and store the message_uuid in the DATABASE.
// Example DB: await db.markJobAsSent(job.jobId_ resp.message_uuid);
job.status = 'sent';
job.messageUuid = resp.message_uuid; // Store for webhook correlation
// =========================
} catch (error) {
const errorMessage = error?.response?.data?.title || error.message || 'Unknown Vonage API error';
console.error(`[Scheduler] Error sending SMS for job ${job.jobId}:`_ errorMessage_ error?.response?.data || '');
// === PERSISTENCE LOGIC ===
// Update job status to 'failed' and store the error in the DATABASE.
// Implement retry counts/logic here.
// Example DB: await db.markJobAsFailed(job.jobId_ errorMessage_ /* increment retry count */);
job.status = 'failed';
job.error = errorMessage;
// =========================
}
}
// Optional: Clean up old completed/failed jobs from the in-memory store if not using a DB
// for (const [jobId_ job] of scheduledJobs.entries()) { ... }
});
// --- Start Server ---
app.listen(PORT_ () => {
console.log(`Server listening on http://localhost:${PORT}`);
console.log(`NGROK URL (for dev testing): YOUR_NGROK_URL`); // Remind user
// cron.schedule starts the task automatically
});
// --- Graceful Shutdown ---
const gracefulShutdown = (signal) => {
console.log(`${signal} signal received: closing HTTP server and scheduler...`);
schedulerTask.stop();
// TODO: Add code here to close database connections gracefully
console.log('Scheduler stopped.');
// Allow time for existing requests/jobs to finish if needed
setTimeout(() => {
console.log('Exiting process.');
process.exit(0);
}, 1000); // Adjust timeout as needed
};
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
Explanation:
- Initialization: Loads
.env
, initializes Express, Helmet, Vonage SDK (reading the key file). Exits if Vonage setup fails. - In-Memory Store: The
scheduledJobs
Map
is clearly marked as demonstration only and unsuitable for production due to data loss on restart. Comments indicate where database operations are needed. /schedule
Endpoint:- Applies rate limiting (
express-rate-limit
). - Uses
express-validator
for robust input validation. - Generates
jobId
, createsjobDetails
. - Stores the job (in memory for demo, marked for DB replacement).
- Responds
202 Accepted
.
- Applies rate limiting (
- Webhook Endpoints:
/webhooks/status
: Receives delivery reports. Logs data. Includes logic placeholders for finding the job bymessage_uuid
(which needs to be stored upon successful send) and updating status (marked for DB replacement). Crucially notes the need for signature verification./webhooks/inbound
: Receives incoming SMS. Logs data. Placeholder for processing logic (STOP/HELP). Notes need for signature verification.
/health
Endpoint: Basic health check.- Scheduler (
node-cron
):- Warns about
node-cron
limitations. - Runs periodically (
SCHEDULER_INTERVAL_SECONDS
). - Fetches pending jobs (from memory for demo, marked for DB replacement with concurrency considerations).
- For each due job:
- Marks job as 'processing' (in memory for demo, marked for DB replacement, includes race condition caveat).
- Calls
vonage.messages.send()
withclient_ref: jobId
. - On success: Updates status to 'sent' and stores
message_uuid
(in memory for demo, marked for DB replacement). - On failure: Logs error, updates status to 'failed' (in memory for demo, marked for DB replacement, notes need for retry logic).
- Warns about
- Server Start & Shutdown: Starts Express, logs reminder about
ngrok
, handlesSIGINT
/SIGTERM
for graceful shutdown (stopping cron, placeholder for DB connection closing).
4. Building a Complete API Layer
The /schedule
endpoint uses express-validator
for robust validation.
API Endpoint Documentation:
- Endpoint:
POST /schedule
- Description: Schedules an SMS message.
- Request Body (JSON):
{ ""to"": ""+14155551212"", // Destination phone number (E.164 format recommended) ""text"": ""Your appointment is tomorrow at 10 AM."", // Message content ""sendAt"": ""2025-05-15T10:00:00Z"" // Desired send time (ISO 8601 format, UTC recommended) }
- Success Response (202 Accepted):
{ ""jobId"": ""a1b2c3d4-e5f6-7890-1234-567890abcdef"", // Unique ID for the scheduled job ""status"": ""scheduled"", ""sendAt"": ""2025-05-15T10:00:00.000Z"" // The scheduled time in ISO 8601 format }
- Error Responses:
400 Bad Request
: Invalid input (missing fields, invalid formats, date not in future). Body containserrors
array fromexpress-validator
.429 Too Many Requests
: Rate limit exceeded.500 Internal Server Error
: Unexpected server issue.
Testing with curl
:
Replace placeholders with your ngrok
URL (for development), a destination number, and a future date/time.
# Ensure your server is running: node src/server.js
# Ensure ngrok is running: ngrok http 3000
# Use the ngrok HTTPS URL provided
curl -X POST YOUR_NGROK_URL/schedule \
-H ""Content-Type: application/json"" \
-d '{
""to"": ""+12015551234"",
""text"": ""Hello from the Node.js Scheduler! This is a test."",
""sendAt"": ""2025-12-01T10:30:00Z"" # Adjust to a time a few minutes in the future
}'
Check server logs for scheduling, processing, and sending messages. Verify SMS receipt.
5. Integrating with Vonage (Covered in Setup & Core)
Integration relies on:
- Configuration: Securely managed via
.env
and loaded usingdotenv
. The private key file is read directly. - SDK Initialization: The
@vonage/server-sdk
is initialized with credentials. - Sending:
vonage.messages.send()
call within the scheduler task. - Fallback/Retries: The base code marks jobs as 'failed'. Production systems need robust retries (see Section 6) and potentially dead-letter queues (moving jobs that repeatedly fail to a separate table/queue for investigation).
6. Implementing Error Handling, Logging, and Retry Mechanisms
-
Error Handling:
try...catch
around async operations (Vonage calls, DB operations).- Input validation at the API layer (
express-validator
). - Graceful handling of Vonage SDK initialization errors.
- Centralized error logging (see below).
-
Logging:
- Currently uses
console.*
. Strongly recommend a structured logging library likewinston
orpino
for production. - Benefits: JSON format, log levels (error, warn, info, debug), multiple outputs (console, file, external services).
- Log key events: server start, job schedule/process/send/fail, webhook receipt, errors.
- Currently uses
-
Retry Mechanisms: Essential for handling transient network or API issues. The provided code does not implement retries, but here's how you could add it conceptually using
async-retry
:# Install the library npm install async-retry
// Conceptual retry logic in src/server.js (within the scheduler loop) const retry = require('async-retry'); // Make sure to install it // Inside the cron job loop, when processing a job... // job.status = 'processing'; // Mark as processing (in DB) try { const resp = await retry(async (bail, attemptNumber) => { // bail(error) is used to stop retrying for non-recoverable errors console.log(`[Scheduler] Attempt ${attemptNumber} to send job ${job.jobId}`); try { const vonageResponse = await vonage.messages.send({ /* ... message details ... */ }); // If successful, return the response to exit retry loop return vonageResponse; } catch (error) { // Example: Don't retry client errors (4xx) which usually indicate permanent issues if (error.response && error.response.status >= 400 && error.response.status < 500) { console.error(`[Scheduler] Non-retryable Vonage error for job ${job.jobId}: ${error.response.status}`); bail(new Error(`Non-retryable Vonage error: ${error.response.status}`)); // Stop retrying return; // bail throws_ so this won't be reached_ but good practice } // For other errors (network_ 5xx)_ throw to trigger retry console.warn(`[Scheduler] Retrying job ${job.jobId} due to error: ${error.message}`); throw error; } }_ { retries: 3_ // Number of retries (adjust as needed) factor: 2_ // Exponential backoff factor minTimeout: 1000_ // Initial delay 1s maxTimeout: 10000_ // Max delay 10s onRetry: (error_ attemptNumber) => { console.warn(`[Scheduler] Retrying job ${job.jobId}, attempt ${attemptNumber}. Error: ${error.message}`); } }); // If retry succeeded: console.log(`[Scheduler] Message sent via Vonage for job ${job.jobId} after retries. Message UUID: ${resp.message_uuid}`); // === PERSISTENCE LOGIC === // Update job status to 'sent' and store message_uuid in DB job.status = 'sent'; job.messageUuid = resp.message_uuid; // await db.markJobAsSent(job.jobId, resp.message_uuid); // ========================= } catch (error) { // This catches errors after all retries have failed or if bail() was called const finalErrorMessage = error?.response?.data?.title || error.message || 'Retry failed'; console.error(`[Scheduler] Failed to send SMS for job ${job.jobId} after all retries:`, finalErrorMessage); // === PERSISTENCE LOGIC === // Update job status to 'failed' and store the final error in DB job.status = 'failed'; job.error = finalErrorMessage; // await db.markJobAsFailed(job.jobId, finalErrorMessage, /* final retry count */); // Consider moving to a dead-letter queue here. // ========================= }
7. Creating a Database Schema and Data Layer (Conceptual)
Using the in-memory Map
is strictly for demonstration and will lose all data on restart. A persistent database is mandatory for any real application.
Choice of Database: PostgreSQL (relational), MongoDB (NoSQL), or even Redis (with persistence configured) can work. Choose based on your team's familiarity and application needs.
Example Schema (PostgreSQL):
-- Enum for job status (expand as needed)
CREATE TYPE job_status AS ENUM ('pending', 'processing', 'sent', 'failed', 'delivered', 'undelivered', 'retry');
CREATE TABLE scheduled_sms_jobs (
job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Or use application-generated UUID
to_number VARCHAR(20) NOT NULL,
from_number VARCHAR(20) NOT NULL,
message_text TEXT NOT NULL,
send_at TIMESTAMPTZ NOT NULL, -- Use TIMESTAMPTZ to store timezone info (stores in UTC)
status job_status NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
vonage_message_uuid VARCHAR(50) NULL UNIQUE, -- Unique constraint helps webhook correlation
last_error TEXT NULL,
retry_count INT NOT NULL DEFAULT 0,
client_ref VARCHAR(100) NULL -- Store client_ref if used
);
-- Index for efficient querying by the scheduler (find pending jobs due now)
CREATE INDEX idx_scheduled_sms_jobs_pending_send_at ON scheduled_sms_jobs (send_at) WHERE status = 'pending' OR status = 'retry';
-- Index for looking up jobs by message UUID from webhooks
CREATE INDEX idx_scheduled_sms_jobs_message_uuid ON scheduled_sms_jobs (vonage_message_uuid) WHERE vonage_message_uuid IS NOT NULL;
-- Optional: Index for client_ref lookup
-- CREATE INDEX idx_scheduled_sms_jobs_client_ref ON scheduled_sms_jobs (client_ref) WHERE client_ref IS NOT NULL;
-- Trigger to automatically update the updated_at timestamp on changes
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_jobs_updated_at BEFORE UPDATE
ON scheduled_sms_jobs FOR EACH ROW EXECUTE FUNCTION
update_updated_at_column();
Data Access Layer Implementation (Required Step):
You must replace all interactions with the scheduledJobs
Map in src/server.js
with database operations.
- Use an ORM (Sequelize, Prisma, TypeORM) or a query builder (
knex.js
) to interact with your chosen database. - Scheduling:
INSERT
new job records. - Scheduler Query:
SELECT ... WHERE status IN ('pending', 'retry') AND send_at <= NOW() ... FOR UPDATE SKIP LOCKED
(crucial for concurrency). - Updating Status:
UPDATE ... SET status = ?, vonage_message_uuid = ?, last_error = ?, retry_count = ? WHERE job_id = ?
. - Webhook Correlation:
SELECT ... WHERE vonage_message_uuid = ?
. - Use database migrations tools (
knex-migrate
,Prisma Migrate
, etc.) to manage schema changes.
The provided src/server.js
code includes comments (// === PERSISTENCE LOGIC ===
) indicating exactly where database interactions need to replace the in-memory map operations. This implementation is left as an exercise for the reader, as it depends heavily on the chosen database and library.
8. Adding Security Features
- Input Validation: Implemented using
express-validator
. Ensure thorough validation of all inputs. - Secrets Management: