code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

Developer Guide: Building a Node.js Express App for Scheduled SMS Reminders with Vonage

A guide for building a Node.js/Express application to schedule and send SMS reminders using the Vonage Messages API, covering setup, database, scheduling, API, error handling, and deployment.

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:

  1. Schedule an SMS message to be sent to a specific phone number at a future date and time.
  2. List upcoming scheduled messages (with pagination).
  3. 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:

bash
# Create project directory
mkdir vonage-sms-scheduler
cd vonage-sms-scheduler

# Initialize Node.js project
npm init -y

1.2 Install Dependencies:

bash
# 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:

json
{
  // ... 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:

text
# .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.

dotenv
# .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 use postgresql://postgres:password@localhost:5432/sms_scheduler. For SQLite, it would be file:./prisma/dev.db.
    • Security Warning: The example DATABASE_URL uses default user: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.

bash
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
// 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.

bash
# 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:

bash
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:

bash
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:

javascript
// 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:

javascript
// 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, but node-cron is excellent for many use cases.
  • Why Query Logic? status: 'pending' and sendAt: { 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 on message_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:

javascript
// 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:

javascript
// 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:

javascript
// 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 to sendAt check.
  • Error Handling: try...catch blocks wrap async operations. Errors are passed to the next 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:

javascript
// 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 and console.error. This is acceptable for simple cases or development.
  • Production Recommendation: Use a dedicated logging library like pino (very performant) or winston. 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.

Frequently Asked Questions

How to schedule SMS reminders using Node.js?

Use Node.js with Express, the Vonage Messages API, and node-cron to schedule and send SMS reminders. This involves setting up a backend service with a REST API to handle scheduling, listing, and canceling messages. The service checks for due messages and sends them via the Vonage API. The project uses dotenv, PostgreSQL or SQLite, zod, nodemon, jest, and supertest for development, testing and security.

What is the Vonage Messages API used for?

The Vonage Messages API is the core service for sending the actual SMS messages. It's integrated into the Node.js application using the @vonage/server-sdk library. The API key and secret, along with the Vonage virtual number, are essential for sending messages.

Why use node-cron for scheduled SMS?

Node-cron is a simple job scheduler for Node.js, ideal for periodically checking the database for messages due to be sent. While suitable for many applications, for highly critical systems, a dedicated external task queue/scheduler is recommended. The default setting checks every minute but can be adjusted.

When should I use a dedicated task queue instead of node-cron?

While node-cron is suitable for many SMS scheduling scenarios, consider a dedicated external task queue/scheduler for highly critical, distributed systems. Node-cron runs within the Node.js process, so a separate queue offers better fault tolerance and scalability.

Can I use SQLite instead of PostgreSQL?

Yes, you can use SQLite as your database. The Prisma ORM supports multiple database providers. Update the DATABASE_URL in the .env file and prisma/schema.prisma to reflect the SQLite connection string (e.g., file:./prisma/dev.db).

How to set up Vonage API credentials for SMS scheduler?

Obtain your API Key and Secret from the Vonage API Dashboard. Purchase a Vonage virtual phone number capable of sending SMS and put all these credentials in a .env file. Never commit the .env file to version control.

What is the purpose of Zod in the Node.js SMS scheduler?

Zod is used for robust input validation. It ensures that the data received for scheduling messages (phone number, message content, send time) meets the required format and constraints, enhancing security and preventing errors.

How to test the SMS scheduler API endpoints?

The project recommends using Jest and Supertest for automated API testing. You can also use tools like Postman or curl to manually test the API endpoints and verify responses during development or after deployment.

What is Prisma used for in the SMS scheduler project?

Prisma is an Object-Relational Mapper (ORM) that simplifies database interactions. It allows you to define your data models in a schema file (schema.prisma) and generates a type-safe client for querying and managing data in the chosen database (PostgreSQL, SQLite, etc.).

How to handle errors when sending SMS messages?

The code includes try...catch blocks around the Vonage API call and database interactions. Failures update the message status to 'failed' and log the error reason, allowing for debugging and potential retry mechanisms. The improved Vonage response handling focuses on 'message_uuid'.

How to implement pagination for scheduled messages?

The provided /api/schedules endpoint supports pagination using limit and offset parameters in the query string. It returns data along with pagination metadata (total count, limit, offset) for client-side handling. Validation is in place to prevent invalid parameter values.

What does 'pending' status mean in the SMS scheduler?

The 'pending' status indicates that an SMS message is scheduled but hasn't been sent yet. The scheduler will look for messages with this status and a sendAt time in the past, then process them. Other statuses include 'sent', 'failed', and 'processing'.

Why store sendAt in UTC for SMS reminders?

Storing the sendAt time in Coordinated Universal Time (UTC) avoids ambiguity related to time zones. This ensures that the scheduler correctly identifies messages due for sending, regardless of the server's or client's location.

How to cancel a scheduled SMS message?

Send a DELETE request to the /api/schedules/:id endpoint, where :id is the unique identifier of the scheduled message. The system will only allow cancellation if the message status is 'pending', preventing changes to already sent or failed messages.

What is the purpose of the generic error handler?

The generic error handler catches all unhandled errors, provides a consistent JSON error response, and logs detailed error information (including stack trace) to the server console. It enhances the application's robustness and facilitates debugging.