code examples
code examples
Build a Production-Ready SMS Reminder Service with Node.js, Express, and Infobip
A step-by-step guide to building an SMS reminder application using Node.js, Express, PostgreSQL, Prisma, node-cron, and the Infobip API, covering setup, core logic, security, and deployment considerations.
Automated reminders are a powerful tool for user engagement, reducing no-shows, and improving communication. Whether it's for appointments, upcoming payments, subscription renewals, or event notifications, sending timely SMS reminders can significantly enhance user experience and operational efficiency.
This guide provides a step-by-step walkthrough for building a robust SMS reminder service using Node.js, Express, PostgreSQL (with Prisma for ORM), node-cron for scheduling, and the Infobip SMS API for message delivery. We'll cover everything from project setup and core logic to error handling, security, deployment, and monitoring, enabling you to build a reliable, production-grade application.
Project Goals:
- Create a system to store reminders with recipient phone numbers, messages, and scheduled times.
- Implement an API to manage these reminders (create, view, delete).
- Develop a background scheduling mechanism to automatically send SMS reminders via Infobip at the appropriate time.
- Ensure the system is secure, reliable, and observable.
Technologies Used:
- Node.js: A JavaScript runtime for building the backend server.
- Express: A minimal and flexible Node.js web application framework for building the API.
- PostgreSQL: A powerful, open-source relational database to store reminder data.
- Prisma: A modern database toolkit for Node.js and TypeScript, simplifying database access, migrations, and type safety.
- Infobip API & Node.js SDK: For reliable and scalable SMS delivery.
node-cron: A simple cron-like task scheduler for Node.js.dotenv: To manage environment variables securely.express-validator: For robust API request validation.winston: For flexible logging.express-rate-limit: To protect the API against brute-force attacks.
Prerequisites:
- Node.js (v18 or later recommended) and npm/yarn installed.
- Access to a PostgreSQL database instance.
- An Infobip account (a free trial account works for testing, but note limitations on recipient numbers).
- Basic familiarity with Node.js, Express, REST APIs, and databases.
curlor a tool like Postman for testing the API.
System Architecture:
A diagram here would illustrate the flow: Client interacts with the Node.js API, which uses PostgreSQL for storage and the Scheduler. The Scheduler queries the database and triggers SMS sending via the Infobip API.
1. Setting up the Project
Let's start by initializing our Node.js project, installing dependencies, and setting up the basic structure.
1. Initialize Project:
mkdir node-infobip-reminders
cd node-infobip-reminders
npm init -y
# Optional: Initialize Git repository
git init
printf "node_modules\n.env\n" > .gitignore2. Install Core Dependencies:
npm install express dotenv @infobip-api/sdk node-cron prisma @prisma/client pg winston express-validator express-rate-limit libphonenumber-jsexpress: Web framework.dotenv: Loads environment variables from a.envfile.@infobip-api/sdk: The official Infobip Node.js SDK.node-cron: Task scheduler.prisma,@prisma/client: ORM and database client.pg: PostgreSQL driver for Node.js (used by Prisma).winston: Logger.express-validator: Request validation middleware.express-rate-limit: API rate limiting.libphonenumber-js: Robust phone number parsing and validation.
3. Install Development Dependencies:
npm install -D nodemon typescript @types/node @types/express @types/pg @types/node-cron jest @types/jest supertestnodemon: Automatically restarts the server during development.typescriptand@types/*: For optional TypeScript support and type definitions. Even if writing JavaScript, types help with SDK usage and editor integration.jest,@types/jest: Testing framework.supertest: For integration testing API endpoints.
4. Initialize Prisma:
npx prisma init --datasource-provider postgresqlThis command creates:
- A
prismadirectory with aschema.prismafile (for defining your database schema). - A
.envfile (if it doesn't exist) with a placeholderDATABASE_URL.
5. Configure .env:
Open the .env file and configure your database connection string and add placeholders for Infobip credentials.
# .env
# --- Database ---
# Replace with your actual PostgreSQL connection string
# Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
DATABASE_URL="postgresql://user:password@localhost:5432/reminders_db?schema=public"
# --- Infobip ---
# Get these from your Infobip account dashboard
INFOBIP_BASE_URL="YOUR_INFOBIP_BASE_URL" # e.g., https://youraccount.api.infobip.com
INFOBIP_API_KEY="YOUR_INFOBIP_API_KEY"
INFOBIP_SENDER_ID="InfoSMS" # Optional: Customize sender ID (subject to carrier/country rules)
# --- Application ---
PORT=3000
LOG_LEVEL="info" # e.g., error, warn, info, http, verbose, debug, silly
# --- Security ---
# Optional: Add API keys or secrets for securing your own API endpoints
# API_SECRET="your-strong-secret-for-internal-auth"DATABASE_URL: Replaceuser,password,localhost,5432, andreminders_dbwith your actual PostgreSQL credentials and database name. Ensure the database exists.INFOBIP_BASE_URL: Find this in your Infobip account under API Keys. It's specific to your account. Ensure it includes the scheme (https://).INFOBIP_API_KEY: Generate an API key in your Infobip account dashboard.INFOBIP_SENDER_ID: The 'From' name displayed on the SMS. Availability depends on regulations.InfoSMSis a common default.
6. Define Database Schema (prisma/schema.prisma):
Replace the contents of prisma/schema.prisma with the following schema for our reminders:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Reminder {
id String @id @default(cuid())
recipientPhoneNumber String // Store consistently, e.g., E.164 format recommended
message String
reminderTime DateTime // Store in UTC
status Status @default(PENDING) // PENDING, SENT, FAILED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Optional fields for retry logic:
// retryCount Int @default(0)
// lastAttemptTime DateTime?
@@index([reminderTime, status]) // Index for efficient querying by the scheduler
}
enum Status {
PENDING
SENT
FAILED
// RETRYING // Optional status for retry logic
}- We define a
Remindermodel with necessary fields. reminderTimeis crucial and should always be stored in UTC to avoid time zone issues.statustracks whether the reminder has been processed.- An index on
reminderTimeandstatussignificantly speeds up queries performed by the scheduler. - E.164 format (
+15551234567) is recommended for storingrecipientPhoneNumberfor consistency.
7. Apply Database Migrations:
Run the following command to create the Reminder table in your database based on the schema:
npx prisma migrate dev --name initThis command will:
- Create the SQL migration file in
prisma/migrations. - Apply the migration to your database.
- Generate the Prisma Client (
@prisma/client) based on your schema.
8. Project Structure:
Organize your code for better maintainability. Create a src directory:
node-infobip-reminders/
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── src/
│ ├── config/ # Configuration files (Infobip client, logger)
│ ├── controllers/ # Request handlers for API routes
│ ├── routes/ # Express route definitions
│ ├── services/ # Business logic (scheduling, SMS sending)
│ ├── utils/ # Utility functions (error handling, validation)
│ ├── app.js # Express application setup
│ └── server.js # Server entry point
├── .github/ # CI/CD workflows (optional)
│ └── workflows/
│ └── deploy.yml
├── .env # Environment variables (DO NOT COMMIT)
├── .gitignore
├── package.json
└── package-lock.json
Create these directories: mkdir -p src/config src/controllers src/routes src/services src/utils
9. Basic Server Setup (src/server.js and src/app.js):
src/server.js: Entry point to start the server.
// src/server.js
require('dotenv').config(); // Load .env variables early
const app = require('./app');
const logger = require('./config/logger');
const initializeScheduler = require('./services/schedulerService');
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
// Initialize and start the scheduler after the server is running
initializeScheduler();
});
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM signal received: closing HTTP server');
server.close(() => {
logger.info('HTTP server closed');
// Add cleanup logic here if needed (e.g., close DB connection if not handled by Prisma)
process.exit(0);
});
});src/app.js: Express application configuration.
// src/app.js
const express = require('express');
const rateLimit = require('express-rate-limit');
const reminderRoutes = require('./routes/reminderRoutes');
const { handleError } = require('./utils/errorHandler');
const logger = require('./config/logger'); // Assuming logger setup
const app = express();
// --- Middleware ---
// Basic request logging
app.use((req, res, next) => {
logger.http(`Incoming Request: ${req.method} ${req.url}`);
next();
});
// Enable JSON body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Basic Rate Limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
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(limiter);
// --- Routes ---
app.get('/health', (req, res) => res.status(200).send('OK')); // Health check endpoint
app.use('/api/reminders', reminderRoutes); // Reminder API routes
// --- Error Handling ---
// Catch 404s and forward to error handler
app.use((req, res, next) => {
const error = new Error('Not Found');
error.status = 404;
next(error);
});
// Centralized error handler
app.use((err, req, res, next) => {
handleError(err, res); // Use the centralized handler
});
module.exports = app;10. Configure Logger (src/config/logger.js):
// src/config/logger.js
const winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }), // Log stack trace for errors
winston.format.splat(),
winston.format.json() // Log in JSON format
),
defaultMeta: { service: 'reminder-service' },
transports: [
// - Write all logs with importance level of `error` or less to `error.log`
// - Write all logs with importance level of `info` or less to `combined.log`
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
],
});
// If we're not in production then log to the `console` with the format:
// `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
module.exports = logger;11. Add Run Scripts (package.json):
Add scripts to your package.json for convenience:
{
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "jest",
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:studio": "prisma studio"
}
}Now you can run npm run dev to start the server with automatic restarts, npm start to run it normally, and npm test to run tests (once configured).
2. Implementing Core Functionality (Scheduling & SMS Sending)
This is the heart of the service. We'll set up the Infobip client, create a service to send SMS, and build the scheduler to check for due reminders.
1. Configure Infobip Client (src/config/infobipClient.js):
// src/config/infobipClient.js
const { Infobip, AuthType } = require('@infobip-api/sdk');
const logger = require('./logger');
const { INFOBIP_BASE_URL, INFOBIP_API_KEY } = process.env;
if (!INFOBIP_BASE_URL || !INFOBIP_API_KEY) {
logger.error('Infobip Base URL or API Key not found in environment variables.');
// Optionally throw an error or exit if Infobip config is critical at startup
// throw new Error('Missing Infobip configuration.');
}
let infobipClient;
try {
// Ensure base URL has the scheme (e.g., https://)
const baseUrl = INFOBIP_BASE_URL && !INFOBIP_BASE_URL.startsWith('http')
? `https://${INFOBIP_BASE_URL}`
: INFOBIP_BASE_URL;
if (baseUrl && INFOBIP_API_KEY) {
infobipClient = new Infobip({
baseUrl: baseUrl,
apiKey: INFOBIP_API_KEY,
authType: AuthType.ApiKey,
});
logger.info('Infobip client initialized successfully.');
} else {
logger.warn('Infobip client could not be initialized due to missing configuration.');
}
} catch (error) {
logger.error('Failed to initialize Infobip client:', error);
// Handle initialization error appropriately
}
module.exports = infobipClient;- This initializes the Infobip SDK instance using credentials from
.env. - Includes basic error handling for missing configuration.
- Adds a check to ensure the base URL starts with
httpbefore passing it to the SDK.
2. Create SMS Sending Service (src/services/smsService.js):
// src/services/smsService.js
const infobipClient = require('../config/infobipClient');
const logger = require('../config/logger');
const { parsePhoneNumberFromString } = require('libphonenumber-js');
const SENDER_ID = process.env.INFOBIP_SENDER_ID || 'InfoSMS'; // Default sender
/**
* Formats a phone number (potentially E.164) into the digit-only international format
* expected by Infobip (e.g., '447123456789' from '+447123456789').
* Returns null if the number is invalid.
* @param {string} phoneNumberInput - The input phone number string.
* @returns {string|null} The formatted number or null.
*/
const formatPhoneNumberForInfobip = (phoneNumberInput) => {
try {
const phoneNumber = parsePhoneNumberFromString(phoneNumberInput);
if (phoneNumber && phoneNumber.isValid()) {
// Return number without the leading '+'
return phoneNumber.format('E.164').substring(1);
}
} catch (error) {
logger.warn(`Could not parse phone number for Infobip formatting: ${phoneNumberInput}`, error);
}
// Fallback for numbers that might already be in the desired format but fail parsing
// Basic check: starts with digits, contains only digits, length reasonable
if (/^\d{10,15}$/.test(phoneNumberInput)) {
return phoneNumberInput;
}
// Basic non-digit removal as last resort - less reliable
const digitsOnly = phoneNumberInput.replace(/\D/g, '');
if (/^\d{10,15}$/.test(digitsOnly)) {
logger.warn(`Used basic digit removal for Infobip format: ${phoneNumberInput} -> ${digitsOnly}`);
return digitsOnly;
}
logger.error(`Invalid or unformattable phone number for Infobip: ${phoneNumberInput}`);
return null;
}
/**
* Sends an SMS message using the Infobip API.
* @param {string} recipientPhoneNumber - The destination phone number (expects E.164 ideally, but tries to format).
* @param {string} messageText - The text content of the SMS.
* @returns {Promise<object>} - The response object from the Infobip API.
* @throws {Error} - Throws an error if the SMS sending fails or number is invalid.
*/
const sendSms = async (recipientPhoneNumber, messageText) => {
if (!infobipClient) {
logger.error('Infobip client is not initialized. Cannot send SMS.');
throw new Error('SMS service unavailable.');
}
if (!recipientPhoneNumber || !messageText) {
throw new Error('Recipient phone number and message text are required.');
}
// Format the number to the digits-only international format Infobip expects
const formattedPhoneNumber = formatPhoneNumberForInfobip(recipientPhoneNumber);
if (!formattedPhoneNumber) {
throw new Error(`Invalid recipient phone number format: ${recipientPhoneNumber}`);
}
logger.info(`Attempting to send SMS to ${formattedPhoneNumber} (formatted from ${recipientPhoneNumber})`);
try {
const response = await infobipClient.channels.sms.send({
messages: [
{
destinations: [{ to: formattedPhoneNumber }],
from: SENDER_ID,
text: messageText,
},
],
});
// Log success and relevant details
const messageInfo = response.data.messages[0];
logger.info(`SMS sent successfully via Infobip. Bulk ID: ${response.data.bulkId}, Message ID: ${messageInfo.messageId}, Status: ${messageInfo.status.name}`);
// Check status within the response (optional but recommended)
// Status Group IDs: 0 (OK), 1 (PENDING), 2 (UNDELIVERABLE - initial check), 3 (FAILED), 4 (REJECTED), 5 (ACCEPTED - DLR pending)
// Groups >= 3 often indicate issues. Refer to Infobip docs for details.
if (messageInfo.status.groupId >= 3) {
logger.warn(`Infobip reported a non-successful status for message ${messageInfo.messageId}: ${messageInfo.status.name} (${messageInfo.status.description})`);
// Depending on the status, you might still consider it 'sent' from the app's perspective,
// but logging helps diagnose delivery issues later using Infobip reports.
}
return response.data; // Return the body of the Infobip response
} catch (error) {
// Log detailed Infobip API errors if available
let errorMessage = error.message;
if (error.response && error.response.data) {
errorMessage = `Infobip API Error: ${JSON.stringify(error.response.data)}`;
} else if (error.request) {
errorMessage = `Infobip request failed: No response received.`;
}
logger.error(`Error sending SMS via Infobip to ${formattedPhoneNumber}: ${errorMessage}`, error);
// Rethrow a more specific error or handle based on error type
// e.g., check error.response.status for specific HTTP errors from Infobip
throw new Error(`Failed to send SMS: ${error.message}`);
}
};
module.exports = {
sendSms,
};- Encapsulates the logic for sending an SMS.
- Uses the configured
infobipClient. - Uses
libphonenumber-jsto attempt parsing and formatting the number to the digit-only international format Infobip usually expects (e.g.,44...from+44...). Includes basic fallbacks but throws an error if formatting fails. - Logs success and error details, including Infobip's
messageIdwhich is crucial for debugging. - Includes basic checking of the response status group ID. Refer to Infobip documentation for detailed status meanings.
- Improved logging for Infobip API errors.
3. Implement the Scheduler (src/services/schedulerService.js):
// src/services/schedulerService.js
const cron = require('node-cron');
const { PrismaClient } = require('@prisma/client');
const { sendSms } = require('./smsService');
const logger = require('../config/logger');
const prisma = new PrismaClient();
// Schedule task to run every minute
// Adjust the cron expression as needed (e.g., '*/5 * * * *' for every 5 minutes)
const cronExpression = '* * * * *'; // Runs every minute
let taskRunning = false; // Simple lock to prevent overlapping runs
const checkAndSendReminders = async () => {
if (taskRunning) {
logger.warn('Reminder check is already running. Skipping this cycle.');
return;
}
taskRunning = true;
logger.info('Running scheduled reminder check...');
try {
// Calculate the time window for reminders due now
// Look for reminders due in the *past* minute up to *now* (UTC)
const now = new Date();
// Add a small buffer (e.g., 1 second) to avoid missing reminders exactly on the minute boundary due to timing precision
const adjustedNow = new Date(now.getTime() + 1000);
const oneMinuteAgo = new Date(now.getTime() - 60 * 1000);
// Find pending reminders within the time window
const dueReminders = await prisma.reminder.findMany({
where: {
status: 'PENDING',
reminderTime: {
gte: oneMinuteAgo, // Greater than or equal to one minute ago
lte: adjustedNow, // Less than or equal to now (+ buffer)
},
},
// Optional: Add retry logic query here if implemented
// where: {
// OR: [
// { status: 'PENDING', reminderTime: { gte: oneMinuteAgo, lte: adjustedNow } },
// { status: 'RETRYING', retryCount: { lt: MAX_RETRIES }, lastAttemptTime: { lte: calculateNextAttemptTime() } }
// ]
// }
});
if (dueReminders.length === 0) {
logger.info('No pending reminders due at this time.');
taskRunning = false;
return;
}
logger.info(`Found ${dueReminders.length} reminders to send.`);
// Process each reminder
for (const reminder of dueReminders) {
try {
logger.info(`Processing reminder ID: ${reminder.id} for ${reminder.recipientPhoneNumber}`);
await sendSms(reminder.recipientPhoneNumber, reminder.message);
// Update reminder status to SENT on successful send
await prisma.reminder.update({
where: { id: reminder.id },
data: { status: 'SENT', /* lastAttemptTime: new Date() */ }, // Reset retry fields if needed
});
logger.info(`Reminder ID: ${reminder.id} marked as SENT.`);
} catch (error) {
logger.error(`Failed to process reminder ID: ${reminder.id}. Error: ${error.message}`);
// Update reminder status to FAILED (or handle retries)
try {
// Implement retry logic here if desired (see Section 5.3)
// For now, mark as FAILED
await prisma.reminder.update({
where: { id: reminder.id },
data: { status: 'FAILED', /* lastAttemptTime: new Date(), retryCount: { increment: 1 } */ },
});
logger.warn(`Reminder ID: ${reminder.id} marked as FAILED.`);
} catch (updateError) {
logger.error(`Failed to update status to FAILED for reminder ID: ${reminder.id}. Update Error: ${updateError.message}`);
}
}
}
} catch (error) {
logger.error(`Error during scheduled reminder check: ${error.message}`, error);
} finally {
taskRunning = false; // Release the lock
logger.info('Finished scheduled reminder check.');
}
};
// Function to initialize and start the cron job
const initializeScheduler = () => {
if (!cron.validate(cronExpression)) {
logger.error(`Invalid cron expression: ${cronExpression}. Scheduler not started.`);
return;
}
logger.info(`Scheduler initialized. Will run job with cron expression: ${cronExpression}`);
cron.schedule(cronExpression, checkAndSendReminders, {
scheduled: true,
timezone: ""UTC"" // IMPORTANT: Run the cron job check based on UTC time
});
// Optional: Run once immediately on startup if needed
// logger.info('Running initial reminder check on startup...');
// checkAndSendReminders();
};
module.exports = initializeScheduler;- Uses
node-cronto schedulecheckAndSendRemindersto run every minute (* * * * *). Important: The cron job itself runs based on UTC (timezone: ""UTC"") because our database times are in UTC. - Implements a simple
taskRunninglock to prevent the job from running concurrently if the previous run takes longer than a minute. - Calculates a time window (past minute up to now + small buffer) to fetch pending reminders whose
reminderTimefalls within this window. Crucially, this comparison works correctly because bothreminderTimein the DB and thenow/oneMinuteAgovariables are in UTC. - Iterates through due reminders:
- Calls
sendSms. - Updates the reminder status to
SENTon success. - Updates the status to
FAILEDon error. Includes error handling for the status update itself. (Retry logic placeholders added).
- Calls
- Provides robust logging throughout the process.
- The
initializeSchedulerfunction is exported to be called fromserver.jsafter the server starts.
3. Building the API Layer
We need endpoints to create, view, and potentially delete reminders.
1. Define Validation Rules (src/utils/validators.js):
// src/utils/validators.js
const { body, param, validationResult } = require('express-validator');
const { PrismaClient } = require('@prisma/client');
const { parsePhoneNumberFromString, isValidPhoneNumber } = require('libphonenumber-js');
const prisma = new PrismaClient();
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
};
// Validation rules for creating a reminder
const reminderValidationRules = () => {
return [
// recipientPhoneNumber: Must be a valid phone number
body('recipientPhoneNumber')
.trim()
.notEmpty().withMessage('Recipient phone number is required.')
.custom(value => {
// Use isValidPhoneNumber for quick validation without full parsing initially
if (!isValidPhoneNumber(value)) {
throw new Error('Invalid phone number format.');
}
// Further parsing can happen here or in the controller/service if needed
const phoneNumber = parsePhoneNumberFromString(value);
if (!phoneNumber || !phoneNumber.isValid()) { // Double check after parsing
throw new Error('Invalid phone number format.');
}
// Optional: Uncomment the line below to normalize the validated number to E.164 format (e.g., +15551234567)
// before it's passed to the controller. This ensures consistent storage in the database.
// req.body.recipientPhoneNumber = phoneNumber.format('E.164');
return true;
}).withMessage('Must be a valid phone number (international format recommended, e.g., +15551234567).'),
// message: Must not be empty and have a max length
body('message')
.trim()
.notEmpty().withMessage('Message is required.')
.isLength({ max: 1600 }).withMessage('Message exceeds reasonable length limit (consider multi-part SMS if needed).'), // Allow longer messages, Infobip handles segmentation
// reminderTime: Must be a valid ISO8601 date string in the future
body('reminderTime')
.isISO8601({ strict: true, strictSeparator: true }).withMessage('Reminder time must be a valid ISO8601 date string with timezone (e.g., 2025-12-31T10:00:00Z or 2025-12-31T05:00:00-05:00).')
.custom((value) => {
const reminderDate = new Date(value);
if (isNaN(reminderDate.getTime())) {
throw new Error('Invalid date value.'); // Should be caught by isISO8601, but double check
}
if (reminderDate <= new Date()) {
throw new Error('Reminder time must be in the future.');
}
return true;
}),
];
};
// Validation rules for checking ID parameter
const idValidationRules = () => {
return [
param('id').isString().notEmpty().withMessage('Reminder ID is required.')
.isLength({ min: 25, max: 25 }).matches(/^c[a-z0-9]{24}$/).withMessage('Invalid Reminder ID format (must be CUID).') // Prisma default CUID format
];
};
module.exports = {
reminderValidationRules,
idValidationRules,
handleValidationErrors,
};- Uses
express-validatorfor clear and reusable validation logic. - Uses
libphonenumber-jsfor robust phone number validation. Enforces valid format. Includes an optional commented line to normalize to E.164. - Validates
recipientPhoneNumber,message(increased length limit, Infobip handles multi-part), andreminderTime(must be strict ISO8601 format with timezone and in the future). - Includes a middleware
handleValidationErrorsto return formatted errors. - Adds stricter CUID format validation for the ID parameter.
2. Create Reminder Controller (src/controllers/reminderController.js):
// src/controllers/reminderController.js
const { PrismaClient } = require('@prisma/client');
const logger = require('../config/logger');
const { parsePhoneNumberFromString } = require('libphonenumber-js');
const prisma = new PrismaClient();
// Controller to create a new reminder
const createReminder = async (req, res, next) => {
try {
const { message, reminderTime } = req.body;
let { recipientPhoneNumber } = req.body;
// Convert reminderTime string to Date object (it's already validated as ISO8601)
// Prisma expects a Date object for DateTime fields, JS Date handles timezone conversion.
const reminderDate = new Date(reminderTime);
// Ensure phone number is stored in E.164 format for consistency
// The validator already confirmed it's a valid number.
const phoneNumber = parsePhoneNumberFromString(recipientPhoneNumber);
if (!phoneNumber || !phoneNumber.isValid()) {
// This should theoretically not happen if validation passed, but defensive check
throw new Error('Invalid phone number passed validation.');
}
const phoneNumberE164 = phoneNumber.format('E.164');
const newReminder = await prisma.reminder.create({
data: {
recipientPhoneNumber: phoneNumberE164, // Store in E.164 format
message,
reminderTime: reminderDate, // Store as Date object (Prisma handles UTC conversion to DB)
// Status defaults to PENDING via schema
},
});
logger.info(`Reminder created successfully with ID: ${newReminder.id}`);
res.status(201).json({
message: 'Reminder created successfully.',
reminder: {
id: newReminder.id,
recipientPhoneNumber: newReminder.recipientPhoneNumber, // Already E.164
message: newReminder.message,
reminderTime: newReminder.reminderTime.toISOString(), // Return as ISO string (UTC)
status: newReminder.status,
createdAt: newReminder.createdAt.toISOString(),
}
});
} catch (error) {
logger.error(`Error creating reminder: ${error.message}`, { stack: error.stack, body: req.body });
// Pass error to the centralized error handler
next(error);
}
};
// Controller to get a specific reminder by ID
const getReminderById = async (req, res, next) => {
try {
const { id } = req.params;
const reminder = await prisma.reminder.findUnique({
where: { id: id },
});
if (!reminder) {
// Use return to stop execution after sending response
return res.status(404).json({ message: 'Reminder not found.' });
}
res.status(200).json({
id: reminder.id,
recipientPhoneNumber: reminder.recipientPhoneNumber,
message: reminder.message,
reminderTime: reminder.reminderTime.toISOString(),
status: reminder.status,
createdAt: reminder.createdAt.toISOString(),
updatedAt: reminder.updatedAt.toISOString(),
});
} catch (error) {
// Handle potential Prisma errors like invalid ID format during query
if (error.code === 'P2023' || (error.message && error.message.includes('Malformed ObjectID'))) { // Example check for invalid ID format errors
logger.warn(`Attempt to fetch reminder with invalid ID format: ${req.params.id}`);
return res.status(400).json({ message: 'Invalid Reminder ID format.' });
}
logger.error(`Error fetching reminder by ID ${req.params.id}: ${error.message}`, { stack: error.stack });
// Pass other errors to the centralized error handler
next(error);
}
};
// Add other controllers as needed (e.g., listReminders, deleteReminder)
module.exports = {
createReminder,
getReminderById,
// listReminders,
// deleteReminder,
};Frequently Asked Questions
How to send SMS reminders with Node.js?
Use Node.js with Express, PostgreSQL, Prisma, node-cron, and the Infobip SMS API to build a robust reminder service. This setup allows you to store reminder details in a database, schedule sending times, and reliably deliver SMS messages via Infobip's platform.
What is node-cron used for in SMS reminders?
Node-cron is a task scheduler in Node.js that allows you to automate tasks. In an SMS reminder service, it's used to trigger the sending of SMS messages at specified times, ensuring reminders are delivered promptly.
Why does reminder time need to be in UTC?
Storing reminder times in UTC (Coordinated Universal Time) prevents timezone issues. This ensures that reminders are sent at the correct time regardless of the user's location or server settings.
When should I use express-rate-limit in my Node.js API?
Express-rate-limit helps protect your API from abuse, such as brute-force attacks. It limits the number of requests an IP address can make within a time window, enhancing the security and stability of your application.
Can I use a free Infobip account for SMS reminders?
Yes, a free Infobip trial account can be used for testing. Keep in mind there are limitations, such as restrictions on recipient numbers. For production, a paid account is necessary for full functionality and higher message volumes.
How to set up Infobip API in Node.js?
Initialize the Infobip Node.js SDK with your API key and base URL. These credentials can be found in your Infobip account dashboard. Ensure the URL includes "https://" and error handle for missing configurations.
How to schedule SMS reminders with node-cron?
Set up a cron job using node-cron's schedule method. Define the desired schedule (e.g., every minute, daily, etc.) and the function to send reminders. Use UTC timezone to avoid time discrepancies.
What is Prisma used for in a Node.js application?
Prisma simplifies database interactions in Node.js. It's an ORM (Object-Relational Mapper) that allows you to define your database schema and access data using JavaScript objects, making database operations more efficient and type-safe.
How to structure a Node.js project for SMS reminders?
Create directories for config, controllers, routes, services, and utils within a src folder. This structure improves code organization, maintainability, and separation of concerns.
What database is recommended for SMS reminders service?
PostgreSQL is recommended for its reliability and efficiency. It's a powerful open-source relational database suitable for handling large volumes of reminder data and frequent queries.
What are the prerequisites for SMS reminder setup?
You'll need Node.js, access to a PostgreSQL database, an Infobip account, basic familiarity with Node.js, Express, REST APIs, and databases, and tools like cURL or Postman for testing.
How to validate phone numbers for SMS reminders?
Use the libphonenumber-js library in your Node.js project. This library helps validate and format phone numbers according to international standards, ensuring that you're using correct number formats for SMS delivery.
Why is E.164 recommended for phone numbers?
Storing phone numbers in E.164 format (+15551234567) is recommended for consistency and compatibility. The format ensures correct number parsing and avoids issues with different regional formats.
What is winston used for in this reminder project?
Winston provides flexible logging for your application. It helps you track events, errors, and other relevant information, aiding in debugging and monitoring the health of your service.