code examples

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

Build a Node.js SMS Reminder System with Express and Infobip

A step-by-step guide to creating an SMS reminder application using Node.js, Express, Infobip, node-cron, Prisma, and PostgreSQL.

Build a Node.js SMS Reminder System with Express and Infobip

This guide provides a step-by-step walkthrough for building a production-ready SMS reminder application using Node.js, Express, Infobip for sending SMS, node-cron for scheduling, and Prisma with PostgreSQL for data persistence.

We'll cover everything from project setup and core feature implementation to error handling, security, deployment, and testing. By the end, you'll have a robust system capable of scheduling and sending SMS reminders based on user-defined times.

Project Overview and Goals

What We're Building:

An API-driven application that allows users (or other systems) to schedule SMS reminders to be sent at specific future dates and times. The system will reliably send these messages using Infobip's SMS API.

Problems Solved:

  • Automating reminder notifications (e.g., appointments, deadlines, events).
  • Providing a reliable mechanism for scheduled SMS delivery.
  • Creating a decoupled service that can be integrated into larger applications.

Technologies Used:

  • Node.js: JavaScript runtime for the backend server.
  • Express: Web application framework for Node.js, used to build the API.
  • Infobip API & Node.js SDK (@infobip-api/sdk): Cloud communications platform for sending SMS messages. We use the official SDK for easier integration.
  • node-cron: Task scheduler for Node.js, used to trigger reminder checks periodically.
  • Prisma: Next-generation ORM for Node.js and TypeScript, used for database interactions.
  • PostgreSQL: Robust open-source relational database for storing reminder data.
  • dotenv: Module to load environment variables from a .env file.
  • express-validator: Middleware for request data validation.
  • winston: A versatile logging library.
  • express-rate-limit: Basic rate limiting middleware for Express.
  • Docker: For containerizing the application for deployment.

System Architecture:

text
+-----------+       +-----------------+      +-----------------+      +---------+      +---------------+      +-----------+
|   Client  | ----> | Express API     | ---> | Database (PG)   | <--- | Scheduler | ---> | Infobip API   | ---> | End User  |
| (e.g. UI, |       | (Node.js)       |      | (Reminders)     |      | (node-cron) |      | (via SDK)     |      | (via SMS) |
|   script) | <---- |                 |      +-----------------+      +-----------+      +---------------+      +-----------+
+-----------+       +-----------------+
  (Schedule          (Stores/Retrieves       (Checks for due
   Reminder)          Reminder Data)           reminders &
                                               triggers sending)

Prerequisites:

  • Node.js (v18 or later recommended) and npm installed.
  • Access to a PostgreSQL database (local or cloud-based).
  • An Infobip account (a free trial account works, but may have limitations).
  • Basic familiarity with Node.js, Express, APIs, and databases.
  • Docker installed (for deployment section).

Final Outcome:

A running Node.js application with API endpoints to schedule, list, and cancel SMS reminders. A background scheduler will periodically check for due reminders and send them via Infobip.


1. Setting up the Project

Let's initialize our project, install dependencies, and set up the basic structure.

1.1 Create Project Directory

Open your terminal and create a new directory for the project.

bash
mkdir node-sms-reminders
cd node-sms-reminders

1.2 Initialize Node.js Project

bash
npm init -y

This creates a package.json file.

1.3 Install Dependencies

bash
npm install express dotenv @infobip-api/sdk node-cron @prisma/client pg express-validator winston express-rate-limit

1.4 Install Development Dependencies

bash
npm install -D prisma nodemon
  • prisma: The Prisma CLI for database migrations and client generation.
  • nodemon: Utility that automatically restarts the Node application when file changes are detected during development.

1.5 Configure package.json Scripts

Add the following scripts to your package.json for easier development and execution:

json
{
  "name": "node-sms-reminders",
  "version": "1.0.0",
  "description": "SMS Reminder Application",
  "main": "src/server.js",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js",
    "prisma:migrate": "prisma migrate dev",
    "prisma:generate": "prisma generate",
    "prisma:studio": "prisma studio",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "@infobip-api/sdk": "^2.0.0",
    "@prisma/client": "^5.0.0",
    "dotenv": "^16.0.0",
    "express": "^4.18.0",
    "express-rate-limit": "^7.0.0",
    "express-validator": "^7.0.0",
    "node-cron": "^3.0.0",
    "pg": "^8.10.0",
    "winston": "^3.9.0"
  },
  "devDependencies": {
    "nodemon": "^3.0.0",
    "prisma": "^5.0.0"
  }
}

Note: Replace "test" script with your actual test command (e.g., "jest" if using Jest). Ensure dependency versions are appropriate for your project.

1.6 Initialize Prisma

Set up Prisma and connect it to your PostgreSQL database.

bash
npx prisma init --datasource-provider postgresql

This creates:

  • A prisma directory with a schema.prisma file.
  • A .env file (if it doesn't exist) with a DATABASE_URL placeholder.

1.7 Configure Environment Variables (.env)

Open the .env file and add your configuration. Replace placeholders with your actual credentials. Do not commit this file to version control.

dotenv
# .env

# Database Connection
# Example: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
DATABASE_URL="postgresql://postgres:yourpassword@localhost:5432/sms_reminders?schema=public"

# Infobip API Credentials
# Get these from your Infobip account dashboard
INFOBIP_BASE_URL="YOUR_INFOBIP_BASE_URL" # e.g., xyzabc.api.infobip.com
INFOBIP_API_KEY="YOUR_INFOBIP_API_KEY"

# Application Settings
PORT=3000
NODE_ENV=development # Set to 'production' in deployment

# Security
# Generate a strong random string for API key authentication
# LEAVE EMPTY ONLY FOR LOCAL DEVELOPMENT WHERE AUTH IS INTENTIONALLY SKIPPED (NOT RECOMMENDED FOR PROD)
API_SECRET_KEY="YOUR_STRONG_SECRET_API_KEY_FOR_INTERNAL_AUTH"

# Scheduler Settings (Cron pattern: second minute hour day-of-month month day-of-week)
# Check every minute: '* * * * *'
# Check every 5 minutes: '*/5 * * * *'
SCHEDULER_CRON_PATTERN="* * * * *"

# Logging Level (optional, defaults to 'info')
# LOG_LEVEL=debug

Obtaining Infobip Credentials:

  1. Log in to your Infobip Account.
  2. Your Base URL is typically shown on the homepage after login, or navigate to API > API Keys Management. It looks like xxxxxx.api.infobip.com.
  3. Navigate to API > API Keys Management.
  4. Click "New API Key" or use an existing one.
  5. Copy the API Key value.
  6. Important: Ensure your API key has permissions for SMS messaging.

1.8 Define Project Structure

Create the following directory structure within your project root:

text
node-sms-reminders/
├── prisma/
│   ├── schema.prisma
│   └── migrations/
├── src/
│   ├── config/
│   │   ├── index.js         # Load and export environment variables
│   │   ├── logger.js        # Winston logger configuration
│   │   ├── infobip.js       # Infobip client initialization
│   │   └── prisma.js        # Prisma client instance
│   ├── controllers/
│   │   └── reminderController.js # Handles API request logic
│   ├── middleware/
│   │   ├── authMiddleware.js     # API key authentication
│   │   ├── errorMiddleware.js    # Global error handler
│   │   └── validationMiddleware.js # Request validation rules
│   ├── routes/
│   │   └── reminderRoutes.js     # Defines API routes for reminders
│   ├── services/
│   │   ├── reminderService.js    # Business logic for reminders (DB interaction)
│   │   ├── schedulerService.js   # Logic for the cron scheduler
│   │   └── smsService.js         # Logic for sending SMS via Infobip
│   ├── utils/
│   │   └── timeUtils.js        # Time zone handling utilities (optional but recommended)
│   └── server.js          # Main application entry point (Express setup)
├── .env
├── .gitignore
├── package.json
└── package-lock.json
# Optional: jest.config.js, .eslintrc.json, etc.

Create the necessary directories:

bash
mkdir -p src/config src/controllers src/middleware src/routes src/services src/utils

Add node_modules, .env, and potentially build output directories to your .gitignore file.

text
# .gitignore
node_modules
.env
dist
coverage
*.log

Why this structure? Separating concerns (config, routes, controllers, services, middleware) makes the application more organized, maintainable, and testable as it grows.


2. Creating a Database Schema and Data Layer

We need a way to store the reminders. We'll use Prisma and PostgreSQL.

2.1 Define the Prisma Schema

Open prisma/schema.prisma and define the Reminder model.

prisma
// prisma/schema.prisma

generator client {
  provider = ""prisma-client-js""
}

datasource db {
  provider = ""postgresql""
  url      = env(""DATABASE_URL"")
}

model Reminder {
  id        String   @id @default(cuid()) // Unique identifier
  recipient String   // Phone number to send the SMS to (E.164 format recommended)
  message   String   // The content of the reminder message
  sendAt    DateTime // The scheduled time for sending (Store in UTC!)
  status    String   @default(""PENDING"") // PENDING, SENT, FAILED (Consider adding: RETRYING, DELIVERED, UNDELIVERABLE with webhooks)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  // Optional: Add retry count for better error handling
  // retryCount Int @default(0)
  // Optional: Store failure reason
  // failureReason String?

  // Index for efficient querying by the scheduler
  @@index([sendAt, status])
}
  • recipient: Store phone numbers consistently, preferably in E.164 format (e.g., +14155552671).
  • sendAt: Crucially, store this timestamp in UTC to avoid time zone ambiguities. We'll handle conversions at the application layer if needed.
  • status: Tracks the state of the reminder. Consider adding more granular statuses if implementing retries or delivery reports.
  • @@index([sendAt, status]): Creates a database index on sendAt and status, which is vital for the scheduler to efficiently query pending reminders due for sending.

2.2 Create and Apply Database Migration

Run the Prisma migrate command to create the SQL migration file and apply it to your database. Make sure your database server is running and accessible via the DATABASE_URL in .env.

bash
npx prisma migrate dev --name initial-reminder-schema

This command will:

  1. Create a migrations folder inside prisma.
  2. Generate a SQL file describing the changes needed to create the Reminder table.
  3. Apply these changes to your PostgreSQL database specified in DATABASE_URL.
  4. Generate the Prisma Client based on your schema (npx prisma generate).

2.3 Initialize Prisma Client

Create a singleton instance of the Prisma client to be used throughout the application.

javascript
// src/config/prisma.js
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient({
  // Optional: Log queries during development
  // log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
});

export default prisma;

2.4 Create the Reminder Service

This service will contain the business logic for interacting with the Reminder model in the database.

javascript
// src/services/reminderService.js
import prisma from '../config/prisma.js';
import logger from '../config/logger.js';

/**
 * Creates a new reminder in the database.
 * @param {string} recipient - Phone number (E.164 format recommended).
 * @param {string} message - SMS content.
 * @param {Date} sendAt - Scheduled time (UTC).
 * @returns {Promise<object>} The created reminder object.
 */
export const createReminder = async (recipient, message, sendAt) => {
  try {
    const reminder = await prisma.reminder.create({
      data: {
        recipient,
        message,
        sendAt, // Ensure this is a Date object in UTC
        status: 'PENDING',
      },
    });
    logger.info(`Reminder created with ID: ${reminder.id}`);
    return reminder;
  } catch (error) {
    logger.error(`Error creating reminder: ${error.message}`, { error });
    // Re-throw a more specific error or handle as needed
    throw new Error('Failed to create reminder in database.');
  }
};

/**
 * Finds pending reminders that are due to be sent.
 * @param {Date} now - The current time (UTC).
 * @returns {Promise<Array<object>>} An array of due reminders.
 */
export const findDueReminders = async (now = new Date()) => {
  try {
    // Find reminders where sendAt is less than or equal to now AND status is PENDING
    const dueReminders = await prisma.reminder.findMany({
      where: {
        sendAt: {
          lte: now, // Less than or equal to the current time
        },
        status: 'PENDING',
      },
      orderBy: {
        sendAt: 'asc', // Process older reminders first
      },
      take: 100, // Limit batch size to avoid overwhelming the system/API
    });
    // No need to log if zero, scheduler will handle that
    // logger.info(`Found ${dueReminders.length} due reminders.`);
    return dueReminders;
  } catch (error) {
    logger.error(`Error finding due reminders: ${error.message}`, { error });
    throw new Error('Failed to query due reminders.');
  }
};

/**
 * Updates the status of a reminder.
 * @param {string} reminderId - The ID of the reminder.
 * @param {'PENDING' | 'SENT' | 'FAILED'} status - The new status.
 * @param {string} [reason] - Optional reason for failure.
 * @returns {Promise<object|null>} The updated reminder object or null if update failed.
 */
export const updateReminderStatus = async (reminderId, status, reason = null) => {
  try {
    const dataToUpdate = {
      status: status,
      // Potentially add a 'failureReason' field to the schema if needed
      // failureReason: reason
    };
    // Consider adding retryCount update here if implementing retries

    const updatedReminder = await prisma.reminder.update({
      where: { id: reminderId },
      data: dataToUpdate,
    });
    logger.info(`Updated status for reminder ${reminderId} to ${status}`);
    return updatedReminder;
  } catch (error) {
    // Prisma's P2025 error code means record to update not found
    if (error.code === 'P2025') {
        logger.warn(`Attempted to update status for non-existent reminder ID: ${reminderId}`);
        return null; // Indicate not found
    }
    logger.error(`Error updating status for reminder ${reminderId}: ${error.message}`, { status, error });
    // Don't throw here generally, as the primary action (sending) might have succeeded/failed already.
    // Logged the error, scheduler should handle it. Return null to indicate update failure.
    return null;
  }
};

/**
 * Finds a reminder by its ID.
 * @param {string} reminderId - The ID of the reminder.
 * @returns {Promise<object|null>} The reminder object or null if not found.
 */
export const findReminderById = async (reminderId) => {
  try {
    const reminder = await prisma.reminder.findUnique({
      where: { id: reminderId },
    });
    return reminder;
  } catch (error) {
    logger.error(`Error finding reminder by ID ${reminderId}: ${error.message}`, { error });
    throw new Error('Failed to query reminder by ID.');
  }
};

/**
 * Deletes a reminder by its ID.
 * Only allow deletion if it's still PENDING.
 * @param {string} reminderId - The ID of the reminder.
 * @returns {Promise<object>} The deleted reminder object.
 * @throws {Error} If reminder not found (statusCode 404) or not pending (statusCode 400).
 */
export const deletePendingReminder = async (reminderId) => {
  try {
    // Use a transaction to ensure atomicity (check status and delete)
    const result = await prisma.$transaction(async (tx) => {
      const reminder = await tx.reminder.findUnique({
        where: { id: reminderId },
      });

      if (!reminder) {
        logger.warn(`Attempted to delete non-existent reminder: ${reminderId}`);
        // Throw specific error to be caught by controller
        const notFoundError = new Error('Reminder not found.');
        notFoundError.statusCode = 404;
        throw notFoundError;
      }

      if (reminder.status !== 'PENDING') {
        logger.warn(`Attempted to delete reminder ${reminderId} with status ${reminder.status}`);
        // Throw specific error to be caught by controller
        const badRequestError = new Error('Cannot delete reminder that is not in PENDING status.');
        badRequestError.statusCode = 400;
        throw badRequestError;
      }

      const deletedReminder = await tx.reminder.delete({
        where: { id: reminderId },
      });
      logger.info(`Deleted reminder ${reminderId}`);
      return deletedReminder;
    });
    return result;
  } catch (error) {
     // If it's one of our custom errors with statusCode, re-throw it
    if (error.statusCode === 400 || error.statusCode === 404) {
        throw error;
    }
    // Otherwise, log and throw a generic error
    logger.error(`Error deleting reminder ${reminderId}: ${error.message}`, { error });
    throw new Error('Failed to delete reminder.');
  }
};

/**
 * Lists reminders with pagination.
 * @param {number} page - Page number (1-based).
 * @param {number} limit - Items per page.
 * @returns {Promise<{reminders: Array<object>, total: number, pages: number, currentPage: number}>}
 */
export const listReminders = async (page = 1, limit = 20) => {
  try {
    const skip = (page - 1) * limit;
    const [reminders, total] = await prisma.$transaction([
      prisma.reminder.findMany({
        skip: skip,
        take: limit,
        orderBy: {
          createdAt: 'desc', // Show newest first
        },
      }),
      prisma.reminder.count(),
    ]);

    return {
      reminders,
      total,
      pages: Math.ceil(total / limit),
      currentPage: page,
    };
  } catch (error) {
    logger.error(`Error listing reminders: ${error.message}`, { page, limit, error });
    throw new Error('Failed to list reminders.');
  }
};

3. Integrating with Infobip for SMS Sending

Now, let's set up the Infobip SDK and create a service to handle sending SMS messages.

3.1 Configure Environment Variables Loader

Create a utility to load and validate essential environment variables.

javascript
// src/config/index.js
import dotenv from 'dotenv';

dotenv.config(); // Load .env file contents into process.env

const config = {
  nodeEnv: process.env.NODE_ENV || 'development',
  port: process.env.PORT || 3000,
  databaseUrl: process.env.DATABASE_URL,
  infobip: {
    baseUrl: process.env.INFOBIP_BASE_URL,
    apiKey: process.env.INFOBIP_API_KEY,
  },
  scheduler: {
    cronPattern: process.env.SCHEDULER_CRON_PATTERN || '* * * * *', // Default to every minute
  },
  auth: {
    apiKey: process.env.API_SECRET_KEY,
  },
  logLevel: process.env.LOG_LEVEL || 'info',
};

// Basic validation
if (!config.databaseUrl) {
  console.error('FATAL ERROR: Missing environment variable: DATABASE_URL');
  process.exit(1);
}
if (!config.infobip.baseUrl || !config.infobip.apiKey) {
  console.error('FATAL ERROR: Missing Infobip environment variables: INFOBIP_BASE_URL, INFOBIP_API_KEY');
  process.exit(1);
}
// API Key check moved to auth middleware for clarity, only warn on startup if missing
if (!config.auth.apiKey && config.nodeEnv === 'production') {
  console.warn('SECURITY WARNING: API_SECRET_KEY is not set in production environment. API authentication is disabled!');
} else if (!config.auth.apiKey) {
    console.warn('INFO: API_SECRET_KEY is not set. API authentication will be skipped (intended for local development only).');
}


export default config;

3.2 Initialize Infobip Client

Set up the Infobip SDK instance.

javascript
// src/config/infobip.js
import { Infobip, AuthType } from '@infobip-api/sdk';
import config from './index.js';
import logger from './logger.js';

let infobipClient;

try {
  if (!config.infobip.baseUrl || !config.infobip.apiKey) {
      throw new Error('Infobip Base URL or API Key is missing in configuration.');
  }
  infobipClient = new Infobip({
    baseUrl: config.infobip.baseUrl,
    apiKey: config.infobip.apiKey,
    authType: AuthType.ApiKey,
  });
  logger.info('Infobip client initialized successfully.');
} catch (error) {
  logger.error('FATAL ERROR: Failed to initialize Infobip client:', { message: error.message });
  // Exit if Infobip client fails to init, as SMS sending is core functionality
  process.exit(1);
}

export default infobipClient;

3.3 Create the SMS Service

This service encapsulates the logic for sending SMS messages via the Infobip SDK.

javascript
// src/services/smsService.js
import infobipClient from '../config/infobip.js';
import logger from '../config/logger.js';

/**
 * Sends an SMS message using the Infobip API.
 * @param {string} recipient - The phone number to send to (E.164 format).
 * @param {string} messageText - The content of the SMS.
 * @param {string} [sender='InfoSMS'] - The sender ID (alpha-numeric). Check Infobip docs for regulations.
 * @returns {Promise<{success: boolean, messageId: string|null, error: string|null, status: object|null}>} Result object.
 */
export const sendSms = async (recipient, messageText, sender = 'InfoSMS') => {
  logger.info(`Attempting to send SMS to ${recipient}`);
  try {
    // Construct the payload according to Infobip SDK requirements
    const payload = {
      messages: [
        {
          destinations: [{ to: recipient }],
          from: sender,
          text: messageText,
          // Add other options like delivery time window, notifyUrl etc. if needed
          // notifyUrl: 'YOUR_DELIVERY_REPORT_WEBHOOK_URL', // Add this for delivery reports
          // See: https://www.infobip.com/docs/api/channels/sms/sms-messaging/outbound-sms/send-sms-message
        },
      ],
      // Optional: Add tracking for grouping messages
      // tracking: { track: 'MY_APP_REMINDERS' }
    };

    const response = await infobipClient.channels.sms.send(payload);

    // Process the response - structure might vary slightly based on SDK version
    // Check the first message result as we're sending one message at a time here
    const messageResult = response?.data?.messages?.[0];

    // Group ID 1 (PENDING) or 3 (DELIVERED_TO_HANDSET - unlikely for initial send response) indicate acceptance by Infobip
    // See: https://www.infobip.com/docs/essentials/response-status-and-error-codes#status-object-and-status-ids
    if (messageResult && (messageResult.status?.groupId === 1 || messageResult.status?.groupId === 3)) {
      logger.info(`SMS submitted successfully to Infobip for ${recipient}. Message ID: ${messageResult.messageId}, Status: ${messageResult.status.name} (${messageResult.status.description})`);
      return {
        success: true,
        messageId: messageResult.messageId,
        status: messageResult.status,
        error: null,
      };
    } else {
      // Handle cases where the API accepted the request but reported an issue with the message itself
      const statusDescription = messageResult?.status?.description || 'Unknown status';
      const errorCode = messageResult?.status?.id || 'N/A';
      const groupName = messageResult?.status?.groupName || 'UNKNOWN_GROUP';
      logger.error(`Infobip API reported issue sending SMS to ${recipient}: ${statusDescription} (Code: ${errorCode}, Group: ${groupName})`, { responseData: response?.data });
      return {
        success: false,
        messageId: messageResult?.messageId || null,
        status: messageResult?.status || null,
        error: `Infobip API Error: ${statusDescription} (Code: ${errorCode}, Group: ${groupName})`,
      };
    }
  } catch (error) {
    // Handle network errors or SDK specific exceptions
    const statusCode = error?.response?.status;
    let apiErrorData = error?.response?.data;
    let errorMessage = error.message; // Default to generic error message

    // Try to extract a more specific error message from Infobip's response structure
    if (apiErrorData?.requestError?.serviceException?.text) {
      errorMessage = apiErrorData.requestError.serviceException.text;
    } else if (apiErrorData?.messages?.[0]?.status?.description) {
       errorMessage = apiErrorData.messages[0].status.description;
    } else if (typeof apiErrorData === 'string' && apiErrorData.length > 0) {
        errorMessage = apiErrorData; // Sometimes the error data is just a string
    }

    logger.error(`Failed to send SMS via Infobip to ${recipient}: ${errorMessage}`, {
      statusCode: statusCode,
      apiError: apiErrorData, // Log detailed API error if available
      originalError: error.message,
    });

    return {
      success: false,
      messageId: null,
      status: null,
      error: `Infobip SDK/API Error: ${errorMessage}${statusCode ? ` (Status Code: ${statusCode})` : ''}`,
    };
  }
};

Key points:

  • We use the @infobip-api/sdk for interaction.
  • The sendSms function takes recipient, message, and an optional sender ID. Sender ID regulations vary by country – consult Infobip documentation. Using a registered number might be required.
  • Error handling distinguishes between SDK/network errors (in catch) and API-reported errors within a successful (2xx) response. More detailed error message extraction is attempted.
  • We check messageResult.status.groupId. According to Infobip docs, group ID 1 (PENDING) generally means the message was accepted for processing. Other group IDs (2-REJECTED, 4-FAILED, 5-EXPIRED) indicate issues. Refer to Infobip Status Codes for details.

4. Implementing Core Functionality: The Scheduler

This is the heart of the reminder system. We'll use node-cron to periodically check for due reminders and trigger the sending process.

4.1 Create the Scheduler Service

javascript
// src/services/schedulerService.js
import cron from 'node-cron';
import config from '../config/index.js';
import logger from '../config/logger.js';
import { findDueReminders, updateReminderStatus } from './reminderService.js';
import { sendSms } from './smsService.js';

let cronJob = null;
let isRunning = false; // Simple lock to prevent overlapping runs

const processDueReminders = async () => {
  if (isRunning) {
    logger.warn('Scheduler job processing is already in progress. Skipping this cycle.');
    return;
  }
  isRunning = true;
  logger.info('Scheduler job started: Checking for due reminders...');

  try {
    const now = new Date(); // Use UTC time
    const dueReminders = await findDueReminders(now);

    if (dueReminders.length === 0) {
      logger.info('No due reminders found.');
      // No 'finally' block needed here, will reach the one below
    } else {
        logger.info(`Processing ${dueReminders.length} due reminders...`);

        // Process reminders sequentially or in parallel (with limits)
        // Sequential processing is simpler and safer for API rate limits initially
        for (const reminder of dueReminders) {
          logger.info(`Processing reminder ID: ${reminder.id} for ${reminder.recipient}`);
          try {
            // 1. Attempt to send SMS
            const smsResult = await sendSms(reminder.recipient, reminder.message);

            // 2. Update status based on SMS result
            if (smsResult.success) {
              await updateReminderStatus(reminder.id, 'SENT');
              logger.info(`Successfully sent reminder ${reminder.id} and updated status to SENT.`);
            } else {
              // SMS sending failed according to Infobip or SDK
              // Note: This basic implementation marks as FAILED on the first attempt.
              // A production system should implement retry logic (e.g., increment retry count, check error type) here.
              await updateReminderStatus(reminder.id, 'FAILED', smsResult.error); // Pass error reason if schema supports it
              logger.error(`Failed to send reminder ${reminder.id}: ${smsResult.error}. Status updated to FAILED.`);
            }
          } catch (processError) {
            // Catch errors during the processing of a single reminder (e.g., DB update failure *after* potential send)
            logger.error(`Error processing reminder ${reminder.id}: ${processError.message}`, { error: processError });
            // Attempt to mark as FAILED if possible, but avoid crashing the whole scheduler run
             try {
               await updateReminderStatus(reminder.id, 'FAILED', processError.message);
             } catch (updateErr) {
               logger.error(`Failed to update status to FAILED for reminder ${reminder.id} after processing error: ${updateErr.message}`);
             }
          }
        } // end for loop
    } // end else block

  } catch (error) {
    // Catch errors related to finding reminders (e.g., DB connection issue)
    logger.error(`Scheduler job failed during reminder lookup: ${error.message}`, { error });
  } finally {
    isRunning = false; // Ensure the lock is always released
    logger.info('Scheduler job finished.');
  }
};

export const startScheduler = () => {
  if (cronJob) {
    logger.warn('Scheduler already started.');
    return;
  }

  const cronPattern = config.scheduler.cronPattern;
  if (!cron.validate(cronPattern)) {
     logger.error(`Invalid cron pattern: ""${cronPattern}"". Scheduler not started.`);
     // Optionally throw an error or prevent app startup
     return;
  }

  logger.info(`Starting scheduler with pattern: ${cronPattern}`);
  // Schedule the task
  cronJob = cron.schedule(cronPattern, processDueReminders, {
    scheduled: true,
    timezone: ""Etc/UTC"" // IMPORTANT: Run cron based on UTC
  });

  // Optional: Run immediately on start for testing or catching up missed jobs
  // logger.info('Running scheduler job immediately on startup...');
  // processDueReminders();
};

export const stopScheduler = () => {
  if (cronJob) {
    logger.info('Stopping scheduler...');
    cronJob.stop();
    cronJob = null;
    logger.info('Scheduler stopped.');
  } else {
    logger.warn('Scheduler is not running or already stopped.');
  }
};

// Graceful shutdown handling integrated into server.js

Key aspects:

  • node-cron: Used to schedule the processDueReminders function based on the SCHEDULER_CRON_PATTERN from the .env file.
  • UTC Timezone: The cron job is configured to run based on UTC (timezone: ""Etc/UTC""). This is critical for consistency, as reminder sendAt times are stored in UTC.
  • Concurrency Lock (isRunning): A simple flag prevents the job from running multiple times concurrently if a previous run takes longer than the interval. For high-load scenarios, a more robust locking mechanism (e.g., using Redis or database advisory locks) might be needed.
  • Error Handling: Errors during the database query or within the loop for a single reminder are caught to prevent the entire scheduler from crashing. Failed reminders are marked FAILED. Note: This version lacks sophisticated retry logic.
  • Batching: findDueReminders fetches reminders in batches (take: 100) to avoid loading too many into memory.
  • Graceful Shutdown: Handled in server.js which calls stopScheduler.

5. Building the API Layer with Express

We need endpoints for external systems or a frontend to interact with our reminder service.

5.1 Configure Logging

Set up Winston for structured logging.

javascript
// src/config/logger.js
import winston from 'winston';
import config from './index.js'; // Import config to get log level

const { combine, timestamp, json, printf, colorize, errors } = winston.format;

// Custom format for console logging with colors and stack traces
const consoleFormat = printf(({ level, message, timestamp, stack, ...metadata }) => {
  let msg = `${timestamp} [${level}]: ${message}`;
  // Only include metadata if it's not empty and not the stack trace
  const meta = Object.keys(metadata).length > 0 ? JSON.stringify(metadata, null, 2) : '';
  if (meta) {
    msg += ` ${meta}`;
  }
  // Add stack trace if available
  if (stack) {
    msg += `\n${stack}`;
  }
  return msg;
});

const logger = winston.createLogger({
  level: config.logLevel || 'info', // Use level from config or default to 'info'
  format: combine(
    errors({ stack: true }), // Log stack traces
    timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
    json() // Default format for files or other transports
  ),
  transports: [
    // Console Transport (conditionally formatted based on NODE_ENV)
    new winston.transports.Console({
      format: combine(
        config.nodeEnv === 'development' ? colorize() : winston.format.uncolorize(), // Colorize only in development
        consoleFormat
      ),
      handleExceptions: true, // Log unhandled exceptions
    }),
    // Optional: File transport for production logs
    // new winston.transports.File({
    //   filename: 'logs/error.log',
    //   level: 'error',
    //   handleExceptions: true,
    //   maxsize: 5242880, // 5MB
    //   maxFiles: 5,
    // }),
    // new winston.transports.File({
    //   filename: 'logs/combined.log',
    //   handleExceptions: true,
    //   maxsize: 5242880, // 5MB
    //   maxFiles: 5,
    // }),
  ],
  exitOnError: false, // Do not exit on handled exceptions
});

// Create a stream object with a 'write' function that will be used by morgan
logger.stream = {
  write: (message) => {
    // Use the 'info' level so the output will be picked up by both transports
    logger.info(message.trim());
  },
};

export default logger;

Frequently Asked Questions

How to set up the Infobip SDK for sending SMS?

The Infobip Node.js SDK (@infobip-api/sdk) is initialized using your Infobip Base URL and API Key. This enables the application to communicate with the Infobip platform and seamlessly send SMS messages.

How to build an SMS reminder system with Node.js?

Build a robust SMS reminder application using Node.js, Express, and Infobip by following a step-by-step guide covering project setup, core features, error handling, security, deployment, and testing. This will create a system capable of scheduling and sending SMS reminders based on user-defined times, leveraging the power of Node.js and Infobip's SMS API.

What is node-cron used for in reminder system?

Node-cron is a task scheduler for Node.js, used to trigger reminder checks periodically. It allows the system to automatically check for due reminders at specified intervals and initiate the SMS sending process.

Why use Prisma with PostgreSQL for SMS reminders?

Prisma, a next-generation ORM, is used with PostgreSQL for data persistence. This combination offers efficient database interactions for storing and retrieving reminder data in a robust and reliable manner, enhancing the system's overall performance.

What are the prerequisites to build this system?

You need Node.js (v18 or later), npm, access to PostgreSQL, an Infobip account, and basic knowledge of Node.js, Express, APIs, and databases. Docker is also recommended for containerized deployment.

How to schedule SMS reminders with Express and Infobip?

The application uses Express to build an API that allows users to schedule SMS reminders for specific dates and times. These reminders are reliably sent using Infobip's SMS API, ensuring timely delivery of messages.

What problems does an SMS reminder system solve?

It automates reminder notifications for events like appointments and deadlines. The system provides a reliable, scheduled SMS delivery mechanism, and its decoupled architecture integrates into larger applications.

What is the architecture of the Node.js SMS reminder system?

A client interacts with the Express API, which communicates with a PostgreSQL database for storing and retrieving data. A node-cron scheduler checks for due reminders, triggering the Infobip API via the SDK to send SMS messages to end-users.

When should I use express-rate-limit in a Node.js API?

Express-rate-limit is employed for basic rate limiting, a crucial security measure that protects your application from abuse by limiting the number of requests a client can make within a specific timeframe. Implementing rate limiting is highly recommended, especially for production environments.

How does the reminder system handle timezones?

Reminder times (`sendAt`) are stored in UTC in the database to avoid ambiguities. The application and the node-cron scheduler are also configured to work in UTC, ensuring consistent and accurate scheduling regardless of user location.

Can I use a free Infobip account for this project?

A free Infobip trial account is sufficient for initial testing and development. However, it may have limitations on message volume and features. For production deployments, consider upgrading to a paid account for greater capacity and support.

What is the role of express-validator in the project?

Express-validator serves as middleware for request data validation, ensuring that the data received by the API conforms to specified criteria (e.g., correct phone number format, message length). This helps improve the reliability and security of the application by preventing unexpected data-related issues.

How can I store reminder data in PostgreSQL?

Reminder data is stored in a PostgreSQL database using a `Reminder` model defined in the Prisma schema. This model includes fields for the recipient, message content, scheduled time, and status, providing a structured way to manage reminder information.

Why does storing sendAt in UTC matter?

Storing the `sendAt` timestamp in UTC prevents time zone-related issues, ensuring that reminders are sent at the correct time regardless of the user's or server's geographic location. This is a best practice for any scheduled task system.

How to implement error handling in the scheduler?

The scheduler includes error handling mechanisms to catch and manage exceptions during the reminder processing loop. This helps prevent the entire scheduler from crashing due to isolated errors and ensures the continued operation of the system.