code examples
code examples
Build a Production-Ready SMS Scheduler with Node.js, Fastify, and Sinch
A step-by-step guide to creating an SMS scheduling application using Node.js, Fastify, Sinch SMS API, PostgreSQL, and Prisma, covering setup, database, API, scheduling, and error handling.
This guide provides a step-by-step walkthrough for building a robust SMS scheduling and reminder application using Node.js, the Fastify web framework, the Sinch SMS API, and PostgreSQL with Prisma. You'll learn how to create an API endpoint to schedule reminders, store them in a database, and use a background job to send SMS messages via Sinch at the designated time.
We aim to create a reliable system capable of handling scheduled messages, including proper error handling, logging, and configuration management, suitable for production environments.
Project Overview and Goals
What We'll Build:
- A Node.js application using the Fastify framework.
- An API endpoint (
POST /reminders) to accept SMS reminder requests (recipient number, message, scheduled time). - Integration with PostgreSQL using the Prisma ORM to store reminder details.
- A background scheduler (
node-cron) to periodically check for due reminders. - Integration with the Sinch SMS API via the
@sinch/sdk-coreto send the SMS messages. - Robust logging, error handling, and configuration management.
Problem Solved:
This application addresses the need to reliably send SMS messages at a future specified time, a common requirement for appointment reminders, notifications, marketing campaigns, and other time-sensitive communications.
Technologies Used:
- Node.js (v20+): A JavaScript runtime built on Chrome's V8 engine. Chosen for its asynchronous nature and large ecosystem, suitable for I/O-bound tasks like API handling and external service integration. Fastify v5 requires Node.js v20 or higher.
- Fastify (v5+): A high-performance, low-overhead web framework for Node.js. Chosen for its speed, developer experience, and robust plugin architecture.
- Sinch SMS API (
@sinch/sdk-core): Provides the functionality to send SMS messages globally. Chosen for its direct integration capabilities via the Node.js SDK. - PostgreSQL: A powerful, open-source object-relational database system. Chosen for its reliability, feature set, and strong community support.
- Prisma: A next-generation Node.js and TypeScript ORM. Chosen for simplifying database access, migrations, and type safety.
node-cron: A simple cron-like job scheduler for Node.js. Chosen for its ease of use in running periodic tasks.dotenv: A zero-dependency module that loads environment variables from a.envfile. Essential for managing configuration and secrets securely.pino-pretty: A development dependency to make Pino logs human-readable.
System Architecture:
graph LR
A[Client/User] -- HTTP POST /reminders --> B(Fastify API);
B -- Writes Reminder --> C(PostgreSQL Database);
D(node-cron Scheduler) -- Runs Periodically --> E{Check DB for Due Reminders};
E -- Finds Due Reminder --> F(Sinch Service Module);
F -- Sends SMS Request --> G(Sinch SMS API);
G -- Delivers SMS --> H(Recipient Phone);
E -- Updates Status --> C;
B -- Logs --> I(Console/Log File);
F -- Logs --> I;
D -- Logs --> I;
style B fill:#f9f,stroke:#333,stroke-width:2px
style C fill:#ccf,stroke:#333,stroke-width:2px
style D fill:#ff9,stroke:#333,stroke-width:2px
style F fill:#9cf,stroke:#333,stroke-width:2px
style G fill:#f66,stroke:#333,stroke-width:2pxPrerequisites:
- Node.js v20.0.0 or later installed.
- npm or yarn package manager.
- Access to a PostgreSQL database instance (local or cloud).
- A Sinch account with API credentials (Project ID, Key ID, Key Secret) and a provisioned Sinch phone number or Service Plan ID capable of sending SMS.
- Basic familiarity with Node.js, APIs, and databases.
curlor a similar tool (like Postman) for testing the API.
Final Outcome:
By the end of this guide, you will have a functional Node.js application that can:
- Accept SMS reminder requests via an API endpoint.
- Store these requests securely in a PostgreSQL database.
- Automatically send the SMS messages at their scheduled times using Sinch.
- Log activities and errors for monitoring and troubleshooting.
1. Setting up the Project
Let's initialize the Node.js project, install dependencies, and configure the basic structure.
1. Create Project Directory:
Open your terminal and create a new directory for the project.
mkdir sinch-fastify-scheduler
cd sinch-fastify-scheduler2. Initialize Node.js Project:
npm init -yThis creates a package.json file with default settings.
3. Install Dependencies:
We need several packages for our application:
# Production Dependencies
npm install fastify @sinch/sdk-core dotenv @prisma/client node-cron pino @fastify/rate-limit
# Development Dependencies
npm install -D prisma pino-pretty nodemonfastify: The web framework.@sinch/sdk-core: The official Sinch Node.js SDK.dotenv: Loads environment variables from.env.@prisma/client: The Prisma database client.node-cron: The job scheduler.pino: Fastify's default logger.@fastify/rate-limit: Plugin for API rate limiting.prisma: The Prisma CLI (development dependency).pino-pretty: Makes logs readable during development.nodemon: Automatically restarts the server on file changes during development.
4. Configure package.json Scripts:
Open your package.json file and add/modify the scripts section:
{
"name": "sinch-fastify-scheduler",
"version": "1.0.0",
"description": "SMS Scheduler using Fastify, Prisma, and Sinch",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js | pino-pretty",
"test": "echo \"Error: no test specified\" && exit 1",
"prisma:migrate:dev": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:deploy": "prisma migrate deploy"
},
"keywords": [
"sinch",
"fastify",
"prisma",
"sms",
"scheduler"
],
"author": "",
"license": "ISC",
"dependencies": {
"@fastify/rate-limit": "^9.1.0",
"@prisma/client": "^5.14.0",
"@sinch/sdk-core": "^1.1.1",
"dotenv": "^16.4.5",
"fastify": "^4.27.0",
"node-cron": "^3.0.3",
"pino": "^9.1.0"
},
"devDependencies": {
"nodemon": "^3.1.2",
"pino-pretty": "^11.1.0",
"prisma": "^5.14.0"
}
}start: Runs the application in production.dev: Runs the application usingnodemonfor auto-reloading and pipes logs throughpino-pretty.prisma:migrate:dev: Creates and applies database migrations during development.prisma:generate: Generates the Prisma Client based on your schema.prisma:deploy: Applies pending migrations in production environments."type": "module": Allows us to useimport/exportsyntax instead ofrequire.
5. Initialize Prisma:
Set up Prisma to manage our database connection and schema.
npx prisma init --datasource-provider postgresqlThis command does two things:
- Creates a
prismadirectory with a basicschema.prismafile. - Creates a
.envfile (if it doesn't exist) and adds aDATABASE_URLplaceholder.
6. Configure Environment Variables (.env):
Open the .env file created by Prisma (or create one if it doesn't exist) and add the following variables. Never commit this file to version control.
# .env
# Database Connection (Prisma)
# Example: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
DATABASE_URL="postgresql://your_db_user:your_db_password@localhost:5432/sms_scheduler?schema=public"
# Sinch API Credentials
SINCH_PROJECT_ID="YOUR_SINCH_PROJECT_ID"
SINCH_KEY_ID="YOUR_SINCH_KEY_ID"
SINCH_KEY_SECRET="YOUR_SINCH_KEY_SECRET"
SINCH_NUMBER="YOUR_SINCH_NUMBER_OR_SERVICE_PLAN_ID" # e.g., +1234567890 or a Service Plan ID
# Application Settings
PORT=3000
NODE_ENV=development # change to 'production' for deployment
CRON_SCHEDULE="* * * * *" # Run scheduler every minute. Adjust as needed.
ENABLE_SCHEDULER=true # Set to false to disable scheduler startDATABASE_URL: Replace the placeholder with your actual PostgreSQL connection string.SINCH_PROJECT_ID,SINCH_KEY_ID,SINCH_KEY_SECRET: Obtain these from your Sinch Customer Dashboard under Access Keys. Navigate to your Sinch account > APIs > Access Keys. Create a new key if needed.SINCH_NUMBER: This is the phone number or Service Plan ID you will send SMS from. You can find assigned numbers or create Service Plan IDs in your Sinch Customer Dashboard. For SMS, navigate to Numbers > Your Numbers or SMS > Service Plans.PORT: The port your Fastify server will listen on.NODE_ENV: Controls application behavior (e.g., logging).CRON_SCHEDULE: Defines how often the scheduler job runs.* * * * *means every minute.ENABLE_SCHEDULER: Allows disabling the scheduler via environment variable.
7. Create Project Structure:
Organize your code for better maintainability. Create the following directories:
mkdir src
mkdir src/routes
mkdir src/services
mkdir src/jobssrc/: Contains the main application code.src/routes/: Holds API route definitions.src/services/: Contains modules for interacting with external services (like Sinch) or database logic.src/jobs/: Contains background job definitions (like the scheduler).
Your project structure should now look similar to this:
sinch-fastify-scheduler/
├── node_modules/
├── prisma/
│ ├── migrations/
│ └── schema.prisma
├── src/
│ ├── jobs/
│ ├── routes/
│ └── services/
├── .env
├── .gitignore
├── package.json
├── package-lock.json
└── server.js (We will create this later)Ensure .env and node_modules/ are listed in your .gitignore file.
2. Creating a Database Schema and Data Layer
We'll use Prisma to define our database schema and interact with the database.
1. Define Prisma Schema:
Open prisma/schema.prisma and define the model for storing reminder information.
// 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
phoneNumber String // Recipient's phone number (E.164 format recommended)
message String // The SMS message content
scheduledAt DateTime // The date and time the SMS should be sent (UTC)
status Status @default(PENDING) // Current status of the reminder
sentAt DateTime? // Timestamp when the SMS was actually sent (or attempted)
error String? // Stores error message if sending failed
createdAt DateTime @default(now()) // Timestamp when the reminder was created
updatedAt DateTime @updatedAt // Timestamp of the last update
@@index([status, scheduledAt]) // Index for efficient querying by the scheduler
}
enum Status {
PENDING // Reminder is scheduled but not yet sent
SENT // Reminder SMS was successfully sent
FAILED // Attempted to send SMS, but it failed
}id: Unique identifier using CUID.phoneNumber: Stores the recipient's number. Store in E.164 format (e.g.,+14155552671) for consistency.message: The text of the SMS.scheduledAt: The core field for scheduling. Store this in UTC to avoid timezone issues. The application logic should handle conversion if necessary.status: Tracks the reminder's state using a Prismaenum. Defaults toPENDING.sentAt: Records when the message was processed.error: Stores details if sending fails.createdAt,updatedAt: Standard timestamp fields.@@index([status, scheduledAt]): Crucial for performance. The scheduler job will query based on these fields, so an index significantly speeds this up.
2. Apply Database Migration:
Run the Prisma migrate command to create the initial migration files and apply the changes to your database.
npx prisma migrate dev --name init- Prisma will introspect the schema.
- It will generate SQL migration files in the
prisma/migrations/directory. - It will apply these migrations to your database specified in
DATABASE_URL. - It will also run
prisma generateautomatically.
3. Initialize Prisma Client:
Create a reusable Prisma Client instance. Create src/services/prisma.js:
// src/services/prisma.js
import { PrismaClient } from '@prisma/client';
// Initialize Prisma Client
const prisma = new PrismaClient({
// Optional: Enable logging for debugging database queries
// log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['error'],
});
export default prisma;This centralizes the Prisma Client initialization.
3. Implementing Proper Error Handling, Logging, and Retry Mechanisms
Robust logging and error handling are essential for production applications.
1. Setup Centralized Logger:
Fastify uses Pino for logging. Let's configure it properly. Create src/services/logger.js:
// src/services/logger.js
import pino from 'pino';
import process from 'node:process';
const isProduction = process.env.NODE_ENV === 'production';
// Configure Pino logger
const logger = pino({
level: isProduction ? 'info' : 'debug', // Log level based on environment
transport: isProduction
? undefined // Default transport (stdout) in production
: {
target: 'pino-pretty', // Pretty print logs in development
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
},
});
export default logger;- Sets log level based on
NODE_ENV. - Uses
pino-prettyonly in development for readability. In production, standard JSON logs are usually preferred for log aggregation systems.
2. Integrate Logger with Fastify:
Modify the main server file (server.js, which we'll create fully in Section 7) to use this logger instance. Fastify v4+ uses the logger option passed as an instance.
// Part of server.js (shown fully later in Section 7)
import Fastify from 'fastify';
import logger from './src/services/logger.js'; // Import our configured logger
const fastify = Fastify({
logger: logger, // Provide our custom Pino instance
});3. Error Handling Strategy:
- API Layer: Fastify has built-in error handling. We'll use route schemas for validation errors. For other errors, we can use
try...catchin handlers or Fastify'ssetErrorHandler. - Service Layer (Sinch, Prisma): Functions should catch specific errors (e.g., database connection errors, Sinch API errors) and either handle them gracefully (e.g., return an error object) or re-throw them to be caught higher up. Log errors with context.
- Scheduler Job: The job needs robust
try...catcharound its main logic (fetching reminders, sending SMS) to prevent the entire scheduler process from crashing due to a single failed reminder. Log errors and update the reminder status toFAILED.
4. Retry Mechanisms (Conceptual):
Directly implementing complex retry logic with exponential backoff can add significant complexity. For this guide, we'll keep it simple:
- Scheduler: If sending an SMS fails, the
statusis set toFAILED, and the error is logged. The scheduler will not automatically retry that specific message in the next run. - Improvement Ideas (Beyond this guide):
- Add a
retryCountfield to theRemindermodel. - Modify the scheduler to pick up
FAILEDreminders withretryCount < MAX_RETRIES. - Implement exponential backoff logic before retrying.
- Consider using a dedicated job queue library (like BullMQ) which has built-in retry mechanisms.
- Add a
Example Error Logging (in sendSms):
We will log errors with context in src/services/sinch.js (defined in the next section).
4. Integrating with Sinch
Now, let's create a service module to handle interactions with the Sinch SMS API.
1. Create Sinch Service Module:
Create src/services/sinch.js:
// src/services/sinch.js
import SinchClient from '@sinch/sdk-core';
import process from 'node:process';
import logger from './logger.js'; // Import the logger we just defined
// Validate essential environment variables
const requiredEnv = ['SINCH_PROJECT_ID', 'SINCH_KEY_ID', 'SINCH_KEY_SECRET', 'SINCH_NUMBER'];
for (const variable of requiredEnv) {
if (!process.env[variable]) {
// Log fatal error and exit if critical config is missing during initialization
logger.fatal(`Missing required environment variable for Sinch Service: ${variable}`);
process.exit(1);
}
}
const sinchClient = new SinchClient({
projectId: process.env.SINCH_PROJECT_ID,
keyId: process.env.SINCH_KEY_ID,
keySecret: process.env.SINCH_KEY_SECRET,
});
const sinchNumber = process.env.SINCH_NUMBER;
/**
* Sends an SMS message using the Sinch API.
* @param {string} to - The recipient phone number (E.164 format).
* @param {string} body - The message content.
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>} - Result object
*/
export const sendSms = async (to, body) => {
if (!to || !body) {
logger.error({ to, body }, 'Sinch Service: Missing recipient or message body');
return { success: false, error: 'Missing recipient or message body' };
}
// Basic validation for E.164 format (adjust regex as needed for stricter validation)
if (!/^\+[1-9]\d{1,14}$/.test(to)) {
logger.warn({ phoneNumber: to }, 'Sinch Service: Potentially invalid phone number format. Sending anyway.');
// Decide whether to throw an error or attempt sending
// return { success: false, error: 'Invalid E.164 phone number format' };
}
logger.info({ to, from: sinchNumber }, 'Sinch Service: Attempting to send SMS');
try {
// Use the SMS API provided by the initialized Sinch client
// Note: Depending on SDK version, the exact method might differ slightly.
// Consult the @sinch/sdk-core documentation for the most current API.
// This example uses `sms.batches.send`.
const response = await sinchClient.sms.batches.send({
to: [to], // Expects an array of recipients
from: sinchNumber,
body: body,
// Optional parameters: delivery_report, expire_at, callback_url etc.
});
// Assuming the response structure includes an ID for successful sends.
// Adjust based on the actual SDK response.
if (response && response.id) {
logger.info({ messageId: response.id, to }, 'Sinch Service: SMS sent successfully');
return { success: true, messageId: response.id };
} else {
// Handle cases where the API might return a 2xx status but indicate failure differently
logger.error({ response, to }, 'Sinch Service: SMS sending failed - Unexpected response format');
return { success: false, error: 'Unexpected Sinch API response format' };
}
} catch (error) {
logger.error({ error: error.message, stack: error.stack, to }, 'Sinch Service: Error sending SMS via Sinch API');
// You might want to check error.response?.data for more specific Sinch error codes/messages
const errorMessage = error.response?.data?.message || error.message || 'Unknown Sinch API error';
return { success: false, error: errorMessage };
}
};- Environment Variable Validation: Checks if crucial Sinch credentials are set before initializing. Exits if missing.
- Client Initialization: Creates the
SinchClientinstance using credentials from.env. sendSmsFunction:- Takes the recipient (
to) and message (body) as arguments. - Includes basic input validation. Adds a basic E.164 format check.
- Calls the Sinch SDK's SMS sending method (
sms.batches.send). Note: Always refer to the official@sinch/sdk-coredocumentation for the specific version you are using. - Uses a
try...catchblock for error handling. - Logs success or failure using the imported logger.
- Returns a structured object indicating success/failure and including the message ID or error details.
- Takes the recipient (
5. Building the API Layer
Let's create the Fastify API endpoint to schedule new reminders.
1. Define API Route:
Create src/routes/reminders.js:
// src/routes/reminders.js
import prisma from '../services/prisma.js';
import logger from '../services/logger.js';
// Define JSON Schema for request validation
const createReminderSchema = {
// Fastify v4 style schema attachment
body: {
type: 'object',
required: ['phoneNumber', 'message', 'scheduledAt'],
properties: {
phoneNumber: {
type: 'string',
description: "Recipient's phone number in E.164 format (e.g., +14155552671)",
// Example basic pattern - consider more robust validation
pattern: '^\\+[1-9]\\d{1,14}$'
},
message: {
type: 'string',
minLength: 1,
maxLength: 1600, // Sinch SMS length limits vary, check documentation
description: 'The content of the SMS message',
},
scheduledAt: {
type: 'string',
format: 'date-time', // ISO 8601 format (e.g., "2025-12-31T18:30:00Z")
description: 'The scheduled time in UTC (ISO 8601 format)',
},
},
additionalProperties: false,
},
response: {
201: { // Success response
description: 'Reminder scheduled successfully',
type: 'object',
properties: {
id: { type: 'string' },
phoneNumber: { type: 'string' },
message: { type: 'string' },
scheduledAt: { type: 'string', format: 'date-time' },
status: { type: 'string', enum: ['PENDING'] }, // Only pending on creation
createdAt: { type: 'string', format: 'date-time' },
},
},
// Add definitions for error responses (400, 500) if desired
// 400: { ... }, 500: { ... }
},
};
/**
* Route handler plugin for /reminders endpoint
* @param {import('fastify').FastifyInstance} fastify
* @param {object} options
*/
async function reminderRoutes(fastify, options) {
fastify.post(
'/reminders',
{ schema: createReminderSchema }, // Attach the schema for validation and serialization
async (request, reply) => {
const { phoneNumber, message, scheduledAt } = request.body;
// Basic check: Ensure scheduled time is in the future
const scheduledTime = new Date(scheduledAt);
if (isNaN(scheduledTime.getTime())) {
logger.warn({ scheduledAt }, 'Invalid date format received for scheduledAt');
reply.code(400); // Bad Request
return { error: 'Invalid scheduledAt date format. Please use ISO 8601 format (UTC).' };
}
if (scheduledTime <= new Date()) {
logger.warn({ scheduledAt }, 'Attempted to schedule reminder in the past');
reply.code(400); // Bad Request
return { error: 'Scheduled time must be in the future.' };
}
try {
logger.info({ phoneNumber, scheduledAt }, 'Creating new reminder');
const newReminder = await prisma.reminder.create({
data: {
phoneNumber,
message,
scheduledAt: scheduledTime, // Store as Date object (Prisma handles conversion)
// status defaults to PENDING
},
});
logger.info({ reminderId: newReminder.id }, 'Reminder created successfully');
reply.code(201); // Created
// Return only specific fields defined in the 201 response schema
// Fastify handles serialization based on the schema
return {
id: newReminder.id,
phoneNumber: newReminder.phoneNumber,
message: newReminder.message,
scheduledAt: newReminder.scheduledAt.toISOString(), // Convert back to ISO string for response
status: newReminder.status,
createdAt: newReminder.createdAt.toISOString(),
};
} catch (error) {
logger.error({ error: error.message, stack: error.stack }, 'Error creating reminder in database');
// Check for specific Prisma errors if needed (e.g., unique constraint violation)
reply.code(500); // Internal Server Error
return { error: 'Failed to schedule reminder due to a server error.' };
}
}
);
}
export default reminderRoutes;- Schema Definition: Defines the expected structure and types for the request
bodyand the successfulresponse(status 201). Includes validation rules (required fields, string formats, date-time format, phone number pattern). Corrected regex pattern. - Route Registration: Defines a
POST /remindersroute usingfastify.post. - Schema Attachment: Attaches
createReminderSchemato the route options. Fastify automatically validates the incoming request body and serializes the response according to the schema. - Input Validation: Adds checks for valid date format and ensures
scheduledAtis in the future. - Database Interaction: Uses the imported
prismaclient tocreatea new reminder record. - Error Handling: Includes a
try...catchblock to handle potential database errors during creation. - Response: Returns a
201 Createdstatus with the newly created reminder's details (filtered by the response schema). Returns appropriate error codes (400, 500) on failure.
6. Implementing Core Functionality (Scheduler Job)
Now, let's create the background job that checks for due reminders and triggers the SMS sending.
1. Create Scheduler Job:
Create src/jobs/scheduler.js:
// src/jobs/scheduler.js
import cron from 'node-cron';
import process from 'node:process';
import prisma from '../services/prisma.js';
import { sendSms } from '../services/sinch.js';
import logger from '../services/logger.js';
const cronSchedule = process.env.CRON_SCHEDULE || '* * * * *'; // Default to every minute if not set
let isJobRunning = false; // Simple lock to prevent overlaps
/**
* Fetches pending reminders that are due and attempts to send them.
*/
async function processDueReminders() {
if (isJobRunning) {
logger.warn('Scheduler job already running, skipping this cycle.');
return;
}
isJobRunning = true;
logger.info('Scheduler job started: Checking for due reminders...');
const now = new Date();
let remindersProcessed = 0;
let remindersFailed = 0;
try {
// Find reminders that are PENDING and scheduled for now or in the past
const dueReminders = await prisma.reminder.findMany({
where: {
status: 'PENDING',
scheduledAt: {
lte: now, // Less than or equal to the current time
},
},
take: 100, // Process in batches to avoid overwhelming resources/APIs
orderBy: {
scheduledAt: 'asc', // Process older ones first
},
});
if (dueReminders.length === 0) {
logger.info('Scheduler job: No due reminders found.');
// No return here, proceed to finally block to release lock
} else {
logger.info(`Scheduler job: Found ${dueReminders.length} due reminders.`);
// Process each reminder
for (const reminder of dueReminders) {
logger.debug({ reminderId: reminder.id }, 'Processing reminder...');
let updateData = {};
try {
const result = await sendSms(reminder.phoneNumber, reminder.message);
if (result.success) {
logger.info({ reminderId: reminder.id, messageId: result.messageId }, 'SMS sent successfully via Sinch.');
updateData = {
status: 'SENT',
sentAt: new Date(),
error: null, // Clear any previous error
};
remindersProcessed++;
} else {
logger.error({ reminderId: reminder.id, error: result.error }, 'Failed to send SMS via Sinch.');
updateData = {
status: 'FAILED',
sentAt: new Date(), // Record attempt time
error: result.error?.substring(0, 500) || 'Unknown Sinch failure', // Store error message (truncated)
};
remindersFailed++;
}
} catch (sendError) {
// Catch errors within the sendSms call itself (less likely with current structure, but good practice)
logger.error({ reminderId: reminder.id, error: sendError.message, stack: sendError.stack }, 'Unexpected error during SMS sending process.');
updateData = {
status: 'FAILED',
sentAt: new Date(),
error: sendError.message?.substring(0, 500) || 'Unexpected processing error',
};
remindersFailed++;
}
// Update reminder status in the database
try {
await prisma.reminder.update({
where: { id: reminder.id },
data: updateData,
});
logger.debug({ reminderId: reminder.id, status: updateData.status }, 'Reminder status updated.');
} catch (dbError) {
// Log DB error but continue processing other reminders
logger.error({ reminderId: reminder.id, error: dbError.message, stack: dbError.stack }, 'Failed to update reminder status in database.');
// Depending on strategy, you might want to retry this DB update later
}
} // End of loop
}
} catch (error) {
// Catch errors related to fetching reminders from DB
logger.error({ error: error.message, stack: error.stack }, 'Scheduler job failed: Error fetching due reminders.');
} finally {
isJobRunning = false; // Release the lock
logger.info(`Scheduler job finished. Processed: ${remindersProcessed}, Failed: ${remindersFailed}`);
}
}
/**
* Initializes and starts the cron job.
*/
export function startScheduler() {
if (!cron.validate(cronSchedule)) {
logger.error(`Invalid CRON_SCHEDULE: ${cronSchedule}. Scheduler not started.`);
return;
}
logger.info(`Starting scheduler with schedule: ${cronSchedule}`);
// Schedule the job
cron.schedule(cronSchedule, processDueReminders, {
scheduled: true,
timezone: ""UTC"" // Explicitly run cron based on UTC
});
// Optional: Run once immediately on startup for testing or catching up
// logger.info('Running scheduler once on startup...');
// processDueReminders();
}- Cron Initialization: Uses
node-cronto schedule theprocessDueRemindersfunction based onCRON_SCHEDULEfrom.env. Runs in UTC. - Concurrency Lock: Uses a simple
isJobRunningflag. Note: This simple flag prevents overlap on a single instance but does not handle concurrency across multiple application instances in a distributed environment. More robust solutions like database advisory locks or dedicated job queues (e.g., BullMQ) are needed for multi-instance deployments. - Fetch Due Reminders: Queries the database using Prisma for
PENDINGreminders wherescheduledAtis less than or equal to the current time (now). Usestakefor batching andorderBy. - Process Each Reminder: Iterates through reminders, calls
sendSms, determinesupdateData, and updates the reminder status (SENTorFAILED) in the database. - Robust Error Handling: Includes
try...catchblocks around DB query, individual SMS sending, and individual DB updates. Usesfinallyto ensure the lock is released. - Logging: Logs the start, end, progress, successes, and failures of the job.
7. Server Setup (server.js)
We need the main entry point to tie everything together. Create server.js in the root directory:
// server.js
import Fastify from 'fastify';
import dotenv from 'dotenv';
import process from 'node:process';
// Load environment variables from .env file
dotenv.config();
import logger from './src/services/logger.js'; // Import our configured logger
import prisma from './src/services/prisma.js'; // Import prisma client (though not directly used here, good to have access)
import reminderRoutes from './src/routes/reminders.js';
import { startScheduler } from './src/jobs/scheduler.js';
import rateLimit from '@fastify/rate-limit';
const isProduction = process.env.NODE_ENV === 'production';
const port = parseInt(process.env.PORT || '3000', 10);
const enableScheduler = process.env.ENABLE_SCHEDULER === 'true';
// Initialize Fastify with our custom logger
const fastify = Fastify({
logger: logger,
});
// Register plugins
// Rate Limiting (optional but recommended)
await fastify.register(rateLimit, {
max: 100, // max requests per window
timeWindow: '1 minute'
});
// Register API routes
await fastify.register(reminderRoutes, { prefix: '/api' }); // Prefix routes with /api
// Graceful shutdown handler
const startGracefulShutdown = async () => {
logger.info('Initiating graceful shutdown...');
try {
// Stop accepting new connections
await fastify.close();
logger.info('Fastify server closed.');
// Disconnect Prisma client
await prisma.$disconnect();
logger.info('Prisma client disconnected.');
// Add any other cleanup logic here (e.g., stop cron jobs if needed, though node-cron usually stops with the process)
process.exit(0);
} catch (err) {
logger.error(err, 'Error during graceful shutdown');
process.exit(1);
}
};
process.on('SIGINT', startGracefulShutdown); // Handle Ctrl+C
process.on('SIGTERM', startGracefulShutdown); // Handle kill commands
// Start the server
const startServer = async () => {
try {
await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces
logger.info(`Server listening on port ${port}`);
// Start the scheduler job if enabled
if (enableScheduler) {
startScheduler();
} else {
logger.warn('Scheduler is disabled via ENABLE_SCHEDULER environment variable.');
}
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
// Run the server start function
startServer();- Environment Variables: Loads
.envusingdotenv. - Imports: Imports Fastify, logger, Prisma, routes, scheduler, and rate-limit plugin.
- Fastify Instance: Creates the Fastify instance with the configured logger.
- Plugin Registration: Registers the
@fastify/rate-limitplugin and thereminderRoutes(prefixing them with/api). Usesawaitfor registration as it can be asynchronous. - Graceful Shutdown: Implements handlers for
SIGINTandSIGTERMsignals to close the server and disconnect Prisma properly before exiting. - Server Start: Defines an
asyncfunctionstartServerto listen on the configured port and host (0.0.0.0makes it accessible externally, adjust if needed). - Scheduler Start: Calls
startScheduler()only ifENABLE_SCHEDULERis true. - Error Handling: Includes
try...catcharoundfastify.listento handle startup errors.
Frequently Asked Questions
How to schedule SMS reminders using Node.js?
You can schedule SMS reminders using Node.js by building an application with Fastify, Prisma, and the Sinch SMS API. The application stores reminder details in a PostgreSQL database and uses node-cron to trigger messages via Sinch at the specified time. This setup ensures reliable delivery of time-sensitive communications.
What is the Sinch SMS API used for in this project?
The Sinch SMS API is used to send the actual SMS messages to recipients. The application integrates with Sinch via the @sinch/sdk-core package, allowing you to send messages globally. This simplifies the process of sending SMS messages from your Node.js application.
Why use Fastify for building a Node.js SMS scheduler?
Fastify is a high-performance web framework known for its speed and extensible plugin architecture. Its efficiency makes it ideal for handling API requests and integrating with external services like the Sinch SMS API and PostgreSQL database.
When should I use a production-ready SMS scheduler like this one?
A production-ready SMS scheduler is ideal when you need reliable, automated delivery of SMS messages at specific times. Use cases include appointment reminders, notifications, marketing campaigns, and other time-sensitive communications requiring accurate scheduling and error management.
What database is used to store SMS reminders in this tutorial?
The tutorial utilizes PostgreSQL, a powerful and reliable open-source relational database, to store the SMS reminder details. This is combined with Prisma, an ORM that simplifies database interactions and ensures type safety within your Node.js application.
How to set up a Sinch SMS scheduler with Node.js?
The tutorial provides step-by-step instructions for setting up a Sinch SMS scheduler with Node.js. This involves installing necessary packages like Fastify, @sinch/sdk-core, and Prisma, configuring environment variables with your Sinch credentials, setting up the database schema, and creating the scheduler job.
What is Prisma used for in this Node.js project?
Prisma is used as an Object-Relational Mapper (ORM) to simplify interactions with the PostgreSQL database. It streamlines database access, schema migrations, and provides type safety, making database operations cleaner and more efficient.
Why is node-cron important for the SMS scheduler?
Node-cron is a task scheduler that enables the application to periodically check the database for due reminders. It's crucial for the automation aspect of the project, as it ensures reminders are sent at the correct scheduled times.
How does error handling work in this SMS scheduler application?
Error handling is implemented throughout the application using try...catch blocks and strategic logging. This includes handling potential errors during API requests, database interactions, and SMS sending via Sinch, ensuring the application remains stable and informative during issues.
Can I modify the frequency of the SMS scheduler?
Yes, you can modify the scheduler's frequency by changing the CRON_SCHEDULE environment variable. The default value is '* * * * *', which means the scheduler runs every minute, but you can customize it using standard cron syntax.
How to handle retries for failed SMS messages in this setup?
While the provided example doesn't include automatic retries, you can implement this by adding a retryCount field to the database schema and modifying the scheduler to check and retry failed messages up to a maximum retry limit. For advanced retry logic, consider using a dedicated job queue library.
What Node.js version is required for the Sinch SMS scheduler?
Node.js version 20.0.0 or later is required for this project. This is primarily because Fastify v5, a key dependency used as the web framework, necessitates Node.js 20 or higher to work properly.
What's the role of pino and pino-pretty in this project?
Pino is the logger used by Fastify, offering efficient structured logging capabilities. Pino-pretty is a development dependency that formats Pino's JSON output into a human-readable format, making debugging easier. In production, pino is usually configured for JSON output compatible with logging systems.
How to configure Sinch credentials for this application?
Sinch credentials (Project ID, Key ID, and Key Secret) should be stored in a .env file in your project's root directory. Never commit this file to version control. The application loads these credentials from the .env file during startup. Obtain your credentials from your Sinch Customer Dashboard under Access Keys and Numbers > Your Numbers or SMS > Service Plans for the Sinch number.