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-core to 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 .env file. 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:2px
Prerequisites:
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.
curl or 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.
Bash
mkdir sinch-fastify-scheduler
cd sinch-fastify-scheduler
2. Initialize Node.js Project:
Bash
npm init -y
This creates a package.json file with default settings.
@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:
JSON
{"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 using nodemon for auto-reloading and pipes logs through pino-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 use import/export syntax instead of require.
5. Initialize Prisma:
Set up Prisma to manage our database connection and schema.
Bash
npx prisma init --datasource-provider postgresql
This command does two things:
Creates a prisma directory with a basic schema.prisma file.
Creates a .env file (if it doesn't exist) and adds a DATABASE_URL placeholder.
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
# .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 start
DATABASE_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.
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
// 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 Prisma enum. Defaults to PENDING.
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.
Bash
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 generate automatically.
3. Initialize Prisma Client:
Create a reusable Prisma Client instance. Create src/services/prisma.js:
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:
JavaScript
// src/services/logger.jsimport pino from'pino';
import process from'node:process';
const isProduction = process.env.NODE_ENV === 'production';
// Configure Pino loggerconst logger = pino({
level: isProduction ? 'info' : 'debug', // Log level based on environmenttransport: isProduction
? undefined// Default transport (stdout) in production
: {
target: 'pino-pretty', // Pretty print logs in developmentoptions: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
},
});
exportdefault logger;
Sets log level based on NODE_ENV.
Uses pino-pretty only 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.
JavaScript
// Part of server.js (shown fully later in Section 7)importFastifyfrom'fastify';
import logger from'./src/services/logger.js'; // Import our configured loggerconst 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...catch in handlers or Fastify's setErrorHandler.
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...catch around 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 to FAILED.
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 status is set to FAILED, 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 retryCount field to the Reminder model.
Modify the scheduler to pick up FAILED reminders with retryCount < MAX_RETRIES.
Implement exponential backoff logic before retrying.
Consider using a dedicated job queue library (like BullMQ) which has built-in retry mechanisms.
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:
JavaScript
// src/services/sinch.jsimportSinchClientfrom'@sinch/sdk-core';
import process from'node:process';
import logger from'./logger.js'; // Import the logger we just defined// Validate essential environment variablesconst 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 = newSinchClient({
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
*/exportconstsendSms = 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 recipientsfrom: 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/messagesconst 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 SinchClient instance using credentials from .env.
sendSms Function:
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-core documentation for the specific version you are using.
Uses a try...catch block 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.
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:
JavaScript
// src/routes/reminders.jsimport prisma from'../services/prisma.js';
import logger from'../services/logger.js';
// Define JSON Schema for request validationconst createReminderSchema = {
// Fastify v4 style schema attachmentbody: {
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 validationpattern: '^\\+[1-9]\\d{1,14}$'
},
message: {
type: 'string',
minLength: 1,
maxLength: 1600, // Sinch SMS length limits vary, check documentationdescription: '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 responsedescription: '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 creationcreatedAt: { 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
*/asyncfunctionreminderRoutes(fastify, options) {
fastify.post(
'/reminders',
{ schema: createReminderSchema }, // Attach the schema for validation and serializationasync (request, reply) => {
const { phoneNumber, message, scheduledAt } = request.body;
// Basic check: Ensure scheduled time is in the futureconst scheduledTime = newDate(scheduledAt);
if (isNaN(scheduledTime.getTime())) {
logger.warn({ scheduledAt }, 'Invalid date format received for scheduledAt');
reply.code(400); // Bad Requestreturn { error: 'Invalid scheduledAt date format. Please use ISO 8601 format (UTC).' };
}
if (scheduledTime <= newDate()) {
logger.warn({ scheduledAt }, 'Attempted to schedule reminder in the past');
reply.code(400); // Bad Requestreturn { 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 schemareturn {
id: newReminder.id,
phoneNumber: newReminder.phoneNumber,
message: newReminder.message,
scheduledAt: newReminder.scheduledAt.toISOString(), // Convert back to ISO string for responsestatus: 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 Errorreturn { error: 'Failed to schedule reminder due to a server error.' };
}
}
);
}
exportdefault reminderRoutes;
Schema Definition: Defines the expected structure and types for the request body and the successful response (status 201). Includes validation rules (required fields, string formats, date-time format, phone number pattern). Corrected regex pattern.
Route Registration: Defines a POST /reminders route using fastify.post.
Schema Attachment: Attaches createReminderSchema to 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 scheduledAt is in the future.
Database Interaction: Uses the imported prisma client to create a new reminder record.
Error Handling: Includes a try...catch block to handle potential database errors during creation.
Response: Returns a 201 Created status with the newly created reminder's details (filtered by the response schema). Returns appropriate error codes (400, 500) on failure.
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:
JavaScript
// src/jobs/scheduler.jsimport 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 setlet isJobRunning = false; // Simple lock to prevent overlaps/**
* Fetches pending reminders that are due and attempts to send them.
*/asyncfunctionprocessDueReminders() {
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 = newDate();
let remindersProcessed = 0;
let remindersFailed = 0;
try {
// Find reminders that are PENDING and scheduled for now or in the pastconst 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/APIsorderBy: {
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 reminderfor (const reminder of dueReminders) {
logger.debug({ reminderId: reminder.id }, 'Processing reminder...');
let updateData = {};
try {
const result = awaitsendSms(reminder.phoneNumber, reminder.message);
if (result.success) {
logger.info({ reminderId: reminder.id, messageId: result.messageId }, 'SMS sent successfully via Sinch.');
updateData = {
status: 'SENT',
sentAt: newDate(),
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: newDate(), // Record attempt timeerror: 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: newDate(),
error: sendError.message?.substring(0, 500) || 'Unexpected processing error',
};
remindersFailed++;
}
// Update reminder status in the databasetry {
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.
*/exportfunctionstartScheduler() {
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-cron to schedule the processDueReminders function based on CRON_SCHEDULE from .env. Runs in UTC.
Concurrency Lock: Uses a simple isJobRunning flag. 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 PENDING reminders where scheduledAt is less than or equal to the current time (now). Uses take for batching and orderBy.
Process Each Reminder: Iterates through reminders, calls sendSms, determines updateData, and updates the reminder status (SENT or FAILED) in the database.
Robust Error Handling: Includes try...catch blocks around DB query, individual SMS sending, and individual DB updates. Uses finally to 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:
JavaScript
// server.jsimportFastifyfrom'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 loggerimport 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 loggerconst fastify = Fastify({
logger: logger,
});
// Register plugins// Rate Limiting (optional but recommended)await fastify.register(rateLimit, {
max: 100, // max requests per windowtimeWindow: '1 minute'
});
// Register API routesawait fastify.register(reminderRoutes, { prefix: '/api' }); // Prefix routes with /api// Graceful shutdown handlerconststartGracefulShutdown = async () => {
logger.info('Initiating graceful shutdown...');
try {
// Stop accepting new connectionsawait fastify.close();
logger.info('Fastify server closed.');
// Disconnect Prisma clientawait 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 serverconststartServer = 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 enabledif (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 functionstartServer();
Environment Variables: Loads .env using dotenv.
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-limit plugin and the reminderRoutes (prefixing them with /api). Uses await for registration as it can be asynchronous.
Graceful Shutdown: Implements handlers for SIGINT and SIGTERM signals to close the server and disconnect Prisma properly before exiting.
Server Start: Defines an async function startServer to listen on the configured port and host (0.0.0.0 makes it accessible externally, adjust if needed).
Scheduler Start: Calls startScheduler() only if ENABLE_SCHEDULER is true.
Error Handling: Includes try...catch around fastify.listen to handle startup errors.
Frequently asked questions
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
POST /reminders) to accept SMS reminder requests (recipient number, message, scheduled time).node-cron) to periodically check for due reminders.@sinch/sdk-coreto send the SMS messages.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:
@sinch/sdk-core): Provides the functionality to send SMS messages globally. Chosen for its direct integration capabilities via the Node.js SDK.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:
Prerequisites:
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:
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:
This creates a
package.jsonfile 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.jsonScripts:Open your
package.jsonfile and add/modify thescriptssection:{ "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.
This command does two things:
prismadirectory with a basicschema.prismafile..envfile (if it doesn't exist) and adds aDATABASE_URLplaceholder.6. Configure Environment Variables (
.env):Open the
.envfile created by Prisma (or create one if it doesn't exist) and add the following variables. Never commit this file to version control.DATABASE_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:
Ensure
.envandnode_modules/are listed in your.gitignorefile.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.prismaand define the model for storing reminder information.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.
prisma/migrations/directory.DATABASE_URL.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;NODE_ENV.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 theloggeroption 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:
try...catchin handlers or Fastify'ssetErrorHandler.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:
statusis set toFAILED, and the error is logged. The scheduler will not automatically retry that specific message in the next run.retryCountfield to theRemindermodel.FAILEDreminders withretryCount < MAX_RETRIES.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 }; } };SinchClientinstance using credentials from.env.sendSmsFunction:to) and message (body) as arguments.sms.batches.send). Note: Always refer to the official@sinch/sdk-coredocumentation for the specific version you are using.try...catchblock for error handling.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;bodyand the successfulresponse(status 201). Includes validation rules (required fields, string formats, date-time format, phone number pattern). Corrected regex pattern.POST /remindersroute usingfastify.post.createReminderSchemato the route options. Fastify automatically validates the incoming request body and serializes the response according to the schema.scheduledAtis in the future.prismaclient tocreatea new reminder record.try...catchblock to handle potential database errors during creation.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(); }node-cronto schedule theprocessDueRemindersfunction based onCRON_SCHEDULEfrom.env. Runs in UTC.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.PENDINGreminders wherescheduledAtis less than or equal to the current time (now). Usestakefor batching andorderBy.sendSms, determinesupdateData, and updates the reminder status (SENTorFAILED) in the database.try...catchblocks around DB query, individual SMS sending, and individual DB updates. Usesfinallyto ensure the lock is released.7. Server Setup (
server.js)We need the main entry point to tie everything together. Create
server.jsin 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();.envusingdotenv.@fastify/rate-limitplugin and thereminderRoutes(prefixing them with/api). Usesawaitfor registration as it can be asynchronous.SIGINTandSIGTERMsignals to close the server and disconnect Prisma properly before exiting.asyncfunctionstartServerto listen on the configured port and host (0.0.0.0makes it accessible externally, adjust if needed).startScheduler()only ifENABLE_SCHEDULERis true.try...catcharoundfastify.listento handle startup errors.