SMS Scheduling with Plivo, Node.js & Fastify: Complete Tutorial
Build a production-ready SMS scheduling and reminder system using Plivo API, Node.js, Fastify, and SQLite. Learn task scheduling, database integration, error handling, and deployment.
This guide provides a complete walkthrough for building a production-ready SMS scheduling and reminder application using Node.js, the Fastify web framework, and the Plivo messaging API. You'll learn how to set up the project, schedule tasks, interact with a database, send SMS messages via Plivo, and handle common production concerns like error handling, security, and deployment.
By the end of this tutorial, you will have a functional application capable of accepting requests to schedule SMS reminders, storing them, and automatically sending them out at the specified time using Plivo.
Project Overview and Goals
What We're Building:
We are creating a backend service that exposes an API to schedule SMS messages (reminders). The service will:
Accept API requests specifying a recipient phone number, a message body, and a future delivery time.
Store these scheduled messages in a database.
Periodically check the database for messages scheduled to be sent.
Use the Plivo API to send the messages at their scheduled time.
Provide basic API endpoints to manage these scheduled reminders.
Problem Solved:
This system automates the process of sending timely SMS notifications or reminders, crucial for appointment confirmations, event alerts, subscription renewals, task deadlines, and more, without manual intervention.
Technologies Used:
Node.js: The JavaScript runtime environment.
Fastify: A high-performance, low-overhead web framework for Node.js (v5.x released September 2024), chosen for its speed, extensibility, and developer-friendly features like built-in validation and logging.
Plivo: A cloud communications platform providing APIs for SMS, voice, and more. We'll use its Node.js SDK for sending SMS.
@fastify/schedule (toad-scheduler): A Fastify plugin for scheduling recurring or one-off tasks within the application, used here to periodically check for due reminders.
better-sqlite3: A simple, fast, and reliable SQLite3 client for Node.js, suitable for storing reminder data in this guide. For larger scale, consider PostgreSQL or MySQL.
dotenv: For managing environment variables securely.
pino-pretty: For development-friendly logging output.
System Architecture:
graph LR
User[API Client/User] -- HTTP Request --> API{Fastify API}
API -- Write/Read --> DB[(SQLite Database)]
Scheduler(Scheduler Plugin @fastify/schedule) -- Triggers --> Task(Reminder Check Task)
Task -- Read Due Reminders --> DB
Task -- Send SMS Request --> Plivo(Plivo SMS API)
Plivo -- Delivers SMS --> Recipient[End User Phone]
API -- Start/Manage --> Scheduler
Prerequisites:
Node.js (v20 "Iron" in Maintenance LTS or v22 "Jod" in Active LTS as of October 2025 – see Node.js releases. Production applications should use Active LTS or Maintenance LTS releases) and npm/yarn installed.
A Plivo account with Auth ID, Auth Token, and an SMS-enabled Plivo phone number.
Basic understanding of Node.js, APIs, and databases.
Access to a terminal or command prompt.
Tools like curl or Postman for API testing.
1. Setting up the Project
Let's initialize the project, install dependencies, and structure the application.
1. Create Project Directory:
Open your terminal and run:
Bash
mkdir fastify-plivo-scheduler
cd fastify-plivo-scheduler
Create a .gitignore file in the root directory to prevent sensitive files and unnecessary directories from being committed to version control:
Text
# .gitignore
node_modules/
.env
*.log
database.db # Or your chosen SQLite filename
8. Configure Environment Variables (.env):
Create a .env file in the root directory. Important: Replace the placeholder values (YOUR_PLIVO_AUTH_ID, YOUR_PLIVO_AUTH_TOKEN, YOUR_PLIVO_PHONE_NUMBER) with your actual credentials obtained from your Plivo Console.
.env
# .env
# Server Configuration
PORT=3000
HOST=0.0.0.0
NODE_ENV=development # change to 'production' for deployment
LOG_LEVEL=info # debug, info, warn, error
# Database Configuration
DATABASE_FILE=./database.db
# Plivo Configuration
PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
PLIVO_SENDER_ID=YOUR_PLIVO_PHONE_NUMBER # Must be in E.164 format, e.g., +14155551234
PORT, HOST: Network configuration for the Fastify server.
PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN: Replace these with your Plivo API credentials found on the Plivo Console dashboard.
PLIVO_SENDER_ID: Replace this with an SMS-enabled Plivo phone number you've purchased, found under Phone Numbers > Your Numbers on the Plivo Console. It must be in E.164 format (international standard for telephone numbering, limited to 15 digits including country code).
9. Basic Logger Configuration:
Create src/config/logger.js:
JavaScript
// src/config/logger.jsimport pino from'pino';
const isProduction = process.env.NODE_ENV === 'production';
const loggerConfig = {
level: process.env.LOG_LEVEL || 'info',
...(isProduction ? {} : { transport: { target: 'pino-pretty' } }), // Use pino-pretty only in dev
};
exportdefaultpino(loggerConfig);
This configures the Pino logger, using pino-pretty for readable logs in development and standard JSON logs in production.
Includes a helper loadEnv function for validation.
Initializes Fastify with our custom logger.
Defines an async buildApp function to register plugins and routes (we'll create these next).
Adds a simple /health check endpoint.
Starts the server, listening on the configured port and host.
Now you have a basic project structure and setup ready. Run npm run dev in your terminal. You should see log output indicating the server has started, listening on port 3000. You can access http://localhost:3000/health in your browser or via curl to verify.
2. Creating a Database Schema and Data Layer (Combined with Plugin Setup)
We'll set up the SQLite database and create the necessary table using a Fastify plugin.
1. Implement the Database Plugin (src/plugins/db.js):
JavaScript
// src/plugins/db.jsimport fp from'fastify-plugin';
importDatabasefrom'better-sqlite3';
asyncfunctiondbConnector(fastify, options) {
const { dbFile } = options;
if (!dbFile) {
thrownewError('Database file path (dbFile) is required for dbPlugin');
}
try {
// Connect to SQLite database. Creates the file if it doesn't exist.const db = newDatabase(dbFile, {
// verbose: fastify.log.debug // Uncomment for detailed DB logging
});
fastify.log.info(`Connected to database: ${dbFile}`);
// --- Define Schema and Create Table ---// Use TEXT for ISO 8601 date strings (UTC recommended)// status: pending, sending, sent, failedconst createTableStmt = `
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phoneNumber TEXT NOT NULL,
message TEXT NOT NULL,
reminderTime TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
createdAt TEXT DEFAULT CURRENT_TIMESTAMP,
updatedAt TEXT DEFAULT CURRENT_TIMESTAMP,
plivoMessageUuid TEXT NULL
);
`;
db.exec(createTableStmt);
fastify.log.info('Ensured "reminders" table exists.');
// Add triggers for updatedAt (optional but good practice)const createTriggerStmt = `
CREATE TRIGGER IF NOT EXISTS update_reminders_updatedAt
AFTER UPDATE ON reminders FOR EACH ROW
BEGIN
UPDATE reminders SET updatedAt = CURRENT_TIMESTAMP WHERE id = OLD.id;
END;
`;
db.exec(createTriggerStmt);
fastify.log.info('Ensured "update_reminders_updatedAt" trigger exists.');
// --- Decorate Fastify Instance ---// Make the 'db' instance available throughout the application
fastify.decorate('db', db);
// --- Cleanup Hook ---// Ensure the database connection is closed gracefully on server shutdown
fastify.addHook('onClose', (instance, done) => {
if (instance.db && instance.db.open) {
instance.db.close();
instance.log.info('Database connection closed.');
}
done();
});
} catch (err) {
fastify.log.error({ err }, 'Failed to connect to or initialize the database');
// Allow the error to propagate up to stop the server startthrow err;
}
}
// Export the plugin using fastify-plugin to avoid encapsulation issues// and make the decorator ('db') available globally.exportdefaultfp(dbConnector, {
name: 'db-connector', // Optional name for the pluginfastify: '4-5.x'// Specify Fastify version compatibility (v4 and v5)
});
Plugin Structure: Uses fastify-plugin (fp) to make the db decorator globally available.
Connection: Connects to the SQLite file specified in the options (passed from app.js).
Schema Definition: Defines the reminders table SQL.
id: Primary key.
phoneNumber: Recipient number (E.164 format).
message: SMS content.
reminderTime: Scheduled time in ISO 8601 format (UTC recommended). Storing as TEXT.
status: Tracks the reminder state ('pending', 'sending', 'sent', 'failed').
createdAt, updatedAt: Timestamps.
plivoMessageUuid: Stores the ID returned by Plivo upon successful sending, useful for tracking.
Table Creation:db.exec() runs the CREATE TABLE IF NOT EXISTS statement, making it idempotent.
Trigger: An optional trigger automatically updates updatedAt on row updates.
Decorator:fastify.decorate('db', db) makes the database connection accessible via fastify.db or request.server.db in handlers and other plugins.
Cleanup: The onClose hook ensures the database connection is closed when the Fastify server stops.
Error Handling: Wraps the logic in a try...catch block to handle connection/initialization errors.
2. Data Access:
With the plugin registered in app.js, you can now access the database in your route handlers (created later) like this:
JavaScript
// Example usage in a route handler (src/routes/reminders.js)asyncfunctioncreateReminderHandler(request, reply) {
const { db } = request.server; // Access the decorated DB instanceconst { phoneNumber, message, reminderTime } = request.body;
try {
const stmt = db.prepare('INSERT INTO reminders (phoneNumber, message, reminderTime) VALUES (?, ?, ?)');
const info = stmt.run(phoneNumber, message, reminderTime);
// ... handle response ...
} catch (err) {
// ... handle error ...
}
}
We use prepared statements (db.prepare(...)) which automatically sanitize inputs, preventing SQL injection vulnerabilities.
3. Integrating with Necessary Third-Party Services (Plivo)
Now, let's create a service module to handle interactions with the Plivo API.
1. Create Plivo Service (src/services/plivoService.js):
JavaScript
// src/services/plivoService.jsimport { Client } from'plivo'; // Use named import for ES Modulesimport { setTimeout } from'node:timers/promises'; // For async delayclassPlivoService {
constructor(authId, authToken, senderId, logger) {
if (!authId || !authToken || !senderId) {
thrownewError('PlivoService requires authId, authToken, and senderId');
}
// Ensure logger is provided, default to console if notthis.logger = logger || console;
try {
this.client = newClient(authId, authToken);
this.senderId = senderId;
this.logger.info('Plivo client initialized successfully.');
} catch (error) {
this.logger.error({ err: error }, 'Failed to initialize Plivo client');
throw error; // Re-throw to prevent service usage with bad config
}
}
/**
* Sends an SMS message using the Plivo API with retry logic.
* @param {string} to - The recipient phone number in E.164 format.
* @param {string} text - The message content.
* @returns {Promise<object|null>} Plivo API response on success, null on failure after retries.
*/asyncsendSms(to, text) {
this.logger.info({ to }, `Attempting to send SMS`);
if (!this.client) {
this.logger.error('Plivo client not initialized. Cannot send SMS.');
returnnull;
}
const maxRetries = 2; // Retry up to 2 times (total 3 attempts)const initialDelay = 500; // Start with 500ms delayfor (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
try {
const response = awaitthis.client.messages.create(
this.senderId, // Sender's phone number (from config)
to, // Receiver's phone number
text // The content of the message
);
this.logger.info({ response, to, attempt }, 'SMS sent successfully via Plivo.');
// Example response: { apiId: '...', message: 'message(s) queued', messageUuid: [ '...' ] }return response; // Success, exit loop
} catch (error) {
this.logger.warn({
err: { message: error.message, statusCode: error.statusCode },
to, attempt, maxRetries
}, `Attempt ${attempt} failed sending SMS via Plivo.`);
// Only retry on specific conditions (e.g., server errors, rate limits if desired)// Plivo error codes: https://www.plivo.com/docs/messaging/troubleshooting/error-codes// Generally retry on 5xx status codes from Plivo or network errors.// Avoid retrying on 4xx errors (like invalid number) as they won't succeed.const shouldRetry = (error.statusCode && error.statusCode >= 500) ||
(error.message && (error.message.includes('ETIMEDOUT') || error.message.includes('ECONNRESET'))); // Example network errorsif (shouldRetry && attempt <= maxRetries) {
const delay = initialDelay * Math.pow(2, attempt - 1); // Exponential backoffthis.logger.info(`Retrying in ${delay}ms...`);
awaitsetTimeout(delay); // Use async setTimeout
} else {
// Don't retry or max retries reached, log final error and exitthis.logger.error({
err: { message: error.message, statusCode: error.statusCode, feedback: error.feedback, error: error.error },
to
}, 'Error sending SMS via Plivo (final attempt or non-retriable error).');
returnnull; // Indicate final failure
}
}
}
// This line should only be reached if the loop finishes without returning (which indicates a failure after retries)returnnull;
}
}
exportdefaultPlivoService;
Class Structure: Encapsulates Plivo logic within a class.
Constructor: Takes Plivo credentials (authId, authToken), the senderId (your Plivo number), and a logger instance. It initializes the Plivo Client. Includes robust error handling for initialization failures.
sendSms Method (with Retries):
Takes the recipient (to) and message text.
Implements a retry loop (for) with exponential backoff (initialDelay * Math.pow(2, attempt - 1)) using node:timers/promises.setTimeout.
Attempts to send SMS using this.client.messages.create().
Retries only on specific conditions (5xx errors from Plivo's API response codes, common network errors like ETIMEDOUT, ECONNRESET). Does not retry on 4xx errors (like invalid number) as they won't succeed.
Includes detailed logging for attempts, success, retries, and final failure.
Returns the Plivo API response on success or null after exhausting retries or encountering a non-retriable error.
2. Integrate into Scheduler Plugin (Update src/plugins/scheduler.js):
We need to instantiate PlivoService and make it available to our scheduled task.
JavaScript
// src/plugins/scheduler.jsimport fp from'fastify-plugin';
import { fastifySchedule } from'@fastify/schedule';
import { SimpleIntervalJob } from'toad-scheduler';
importPlivoServicefrom'../services/plivoService.js'; // Import the serviceimport createSendRemindersTask from'../tasks/sendRemindersTask.js'; // Import the task factoryasyncfunctionschedulerPlugin(fastify, options) {
const { plivoAuthId, plivoAuthToken, plivoSenderId } = options;
// --- Instantiate Plivo Service ---// Pass credentials and the fastify logger to the serviceconst plivoService = newPlivoService(plivoAuthId, plivoAuthToken, plivoSenderId, fastify.log);
// --- Register @fastify/schedule ---await fastify.register(fastifySchedule);
fastify.log.info('@fastify/schedule registered.');
// --- Create and Add Scheduled Task ---// Ensure the scheduler is ready before adding jobs
fastify.ready().then(() => {
fastify.log.info('Fastify instance is ready, setting up scheduled jobs.');
// Check for due reminders every 30 seconds (adjust as needed)const intervalInSeconds = 30;
// Create the task, injecting dependencies (db, plivoService, logger)const task = createSendRemindersTask(fastify.db, plivoService, fastify.log);
// Create the job configurationconst job = newSimpleIntervalJob(
{ seconds: intervalInSeconds, runImmediately: true }, // Run immediately on start, then every interval
task, // The async task function to run
{ id: 'send-reminders-job', preventOverrun: true } // Unique ID, prevent task overlap
);
// Add the job to the schedulertry {
fastify.scheduler.addSimpleIntervalJob(job);
fastify.log.info(`Scheduled job "${job.id}" to run every ${intervalInSeconds} seconds.`);
} catch (err) {
fastify.log.error({ err, jobId: job.id }, 'Failed to add scheduled job');
// Depending on severity, you might want to process.exit(1) here
}
}).catch(err => {
fastify.log.error({ err }, 'Error during fastify.ready() in scheduler plugin');
});
// No decoration needed here, scheduler is accessed via fastify.scheduler
}
exportdefaultfp(schedulerPlugin, {
name: 'scheduler-plugin',
dependencies: ['db-connector'], // Ensure DB is ready before schedulerfastify: '4-5.x'
});
Import PlivoService and Task Factory: Import the necessary modules.
Instantiate PlivoService: Create an instance, passing credentials and the Fastify logger (fastify.log).
Inject Dependencies into Task: Modify the task creation to pass fastify.db, the plivoService instance, and fastify.log to the task factory function (createSendRemindersTask). This makes them available within the task's logic.
Dependency: Added 'db-connector' to dependencies to ensure the database plugin runs first.
4. Implementing Core Functionality (The Reminder Task)
This task runs periodically, finds due reminders, and triggers sending them via the PlivoService.
1. Create the Send Reminders Task (src/tasks/sendRemindersTask.js):
JavaScript
// src/tasks/sendRemindersTask.jsimport { AsyncTask } from'toad-scheduler';
// Factory function to create the task with injected dependenciesfunctioncreateSendRemindersTask(db, plivoService, logger) {
// The actual task logic, returned as an AsyncTaskconsttaskLogic = async () => {
logger.info('Running sendRemindersTask...');
const now = newDate().toISOString(); // Get current time in UTC ISO 8601 formatlet remindersToSend = [];
try {
// --- Find Due Reminders ---// Select reminders that are 'pending' and whose time is now or in the pastconst stmt = db.prepare(`
SELECT id, phoneNumber, message
FROM reminders
WHERE status = 'pending' AND reminderTime <= ?
ORDER BY reminderTime ASC
LIMIT 10 -- Process in batches to avoid overwhelming resources
`);
remindersToSend = stmt.all(now);
logger.info(`Found ${remindersToSend.length} reminders due for sending.`);
} catch (err) {
logger.error({ err }, 'Error fetching pending reminders from database.');
return; // Exit task if DB query fails
}
if (remindersToSend.length === 0) {
logger.info('No pending reminders to send at this time.');
return; // Nothing to do
}
// --- Process Each Reminder ---for (const reminder of remindersToSend) {
const { id, phoneNumber, message } = reminder;
logger.info({ reminderId: id }, 'Processing reminder.');
try {
// --- Mark as Sending (Optimistic Locking) ---// Prevents duplicate sends if the task runs again before completionconst updateSendingStmt = db.prepare(`
UPDATE reminders SET status = 'sending', updatedAt = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'pending' -- Ensure it's still pending
`);
const updateResult = updateSendingStmt.run(id);
if (updateResult.changes === 0) {
// Another process might have picked it up already
logger.warn({ reminderId: id }, 'Reminder was not in pending state when attempting to mark as sending. Skipping.');
continue; // Skip to the next reminder
}
logger.info({ reminderId: id }, 'Marked reminder as "sending".');
// --- Send SMS via Plivo Service ---const plivoResponse = await plivoService.sendSms(phoneNumber, message);
// --- Update Status Based on Plivo Response ---let finalStatus = 'failed'; // Default to failedlet plivoUuid = null;
if (plivoResponse && plivoResponse.messageUuid && plivoResponse.messageUuid.length > 0) {
finalStatus = 'sent';
plivoUuid = Array.isArray(plivoResponse.messageUuid) ? plivoResponse.messageUuid[0] : plivoResponse.messageUuid; // Plivo might return array
logger.info({ reminderId: id, plivoMessageUuid: plivoUuid }, 'Reminder successfully sent.');
} else {
logger.error({ reminderId: id }, 'Failed to send reminder via Plivo (PlivoService indicated failure).');
}
const updateStatusStmt = db.prepare(`
UPDATE reminders
SET status = ?, plivoMessageUuid = ?, updatedAt = CURRENT_TIMESTAMP
WHERE id = ?
`);
updateStatusStmt.run(finalStatus, plivoUuid, id);
logger.info({ reminderId: id, status: finalStatus }, 'Updated final reminder status in database.');
} catch (err) {
logger.error({ err, reminderId: id }, 'Unhandled error processing reminder. Attempting to mark as failed.');
// Attempt to mark as failed even if sending failed partway throughtry {
// Use standard quotes for consistency if preferred, or backticks as originally shownconst failStmt = db.prepare(`UPDATE reminders SET status = 'failed', updatedAt = CURRENT_TIMESTAMP WHERE id = ? AND status != 'sent'`);
failStmt.run(id);
} catch (dbErr) {
logger.error({ err: dbErr, reminderId: id }, 'Failed to mark reminder as failed after processing error.');
}
}
} // End for loop
logger.info('sendRemindersTask finished processing batch.');
}; // End taskLogic// --- Error Handling Wrapper for the Task ---consterrorHandler = (err) => {
logger.error({ err }, 'Fatal error occurred within the scheduled AsyncTask (sendRemindersTask).');
// Consider more robust error reporting here (e.g., Sentry, Better Stack)
};
// --- Return the AsyncTask ---// Wrap the logic in toad-scheduler's AsyncTask for proper handlingreturnnewAsyncTask('send-reminders-task', taskLogic, errorHandler);
}
exportdefault createSendRemindersTask;
Factory Pattern: Uses a factory function createSendRemindersTask to accept dependencies (db, plivoService, logger) via injection. This improves testability.
AsyncTask: The core logic is wrapped in toad-scheduler's AsyncTask which provides structured execution and error handling.
Find Due Reminders:
Queries the reminders table for entries with status = 'pending' and reminderTime <= now().
Uses LIMIT to process reminders in batches, preventing the task from holding resources for too long if there are many due reminders.
Orders by reminderTime to process older ones first.
Optimistic Locking:
Before sending, it updates the reminder's status to 'sending'.
The WHERE id = ? AND status = 'pending' clause ensures that only one instance of the task (or another process) can successfully claim a specific reminder. If updateResult.changes is 0, it means the status was already changed, so the current task skips it.
Send SMS: Calls the injected plivoService.sendSms method (which now includes retry logic).
Update Final Status:
Based on the plivoResponse, it updates the reminder status to 'sent' or 'failed'.
Stores the plivoMessageUuid if the message was sent successfully.
Error Handling: Includes try...catch blocks for database operations and the Plivo call. Attempts to mark the reminder as 'failed' if an error occurs during processing.
Task-Level Error Handler: The errorHandler function passed to AsyncTask catches errors originating directly from the taskLogic execution itself (though internal try/catches handle most specific cases).
5. Building a Complete API Layer
Let's define the API endpoints for managing reminders.
// src/routes/reminders.jsimport { Static, Type } from'@sinclair/typebox'; // For schema validation// --- Request/Response Schemas using TypeBox ---// E.164 regex (simplified version, adjust for stricter needs)const E164Format = /^\+[1-9]\d{1,14}$/;
constReminderBaseSchema = Type.Object({
phoneNumber: Type.RegExp(E164Format, {
description: 'Recipient phone number in E.164 format (e.g., +14155551234)',
examples: ['+14155551234'],
}),
message: Type.String({
minLength: 1,
maxLength: 1600, // Plivo limit for GSM 03.38 7-bit characters (https://support.plivo.com/hc/en-us/articles/360041298072)description: 'The content of the SMS message',
}),
// ISO 8601 format (YYYY-MM-DDTHH:mm:ssZ or YYYY-MM-DDTHH:mm:ss+HH:mm)reminderTime: Type.String({
format: 'date-time',
description: 'Scheduled time in ISO 8601 format (UTC recommended)',
examples: ['2025-12-31T23:59:59Z'],
}),
});
constReminderCreateSchema = ReminderBaseSchema;
constReminderResponseSchema = Type.Intersect([
Type.Object({
id: Type.Integer({ description: 'Unique identifier for the reminder' }),
status: Type.String({ description: 'Current status (pending, sending, sent, failed)' }),
createdAt: Type.String({ format: 'date-time', description: 'Timestamp when the reminder was created' }),
updatedAt: Type.String({ format: 'date-time', description: 'Timestamp when the reminder was last updated' }),
plivoMessageUuid: Type.Union([Type.String(), Type.Null()], { description: 'Plivo message ID if sent, otherwise null' }),
}),
ReminderBaseSchema, // Include base fields
]);
// Type definition for TypeScript users (optional but good practice)
type ReminderCreate = Static<typeofReminderCreateSchema>;
// type ReminderResponse = Static<typeof ReminderResponseSchema>; // If neededconstParamsSchema = Type.Object({
id: Type.Integer({ description: 'Reminder ID' }),
});
type ParamsType = Static<typeofParamsSchema>;
constErrorSchema = Type.Object({
statusCode: Type.Integer(),
error: Type.String(),
message: Type.String(),
});
// --- Route Definitions ---exportdefaultasyncfunctionreminderRoutes(fastify, options) {
const { db } = fastify; // Access db instance decorated by the plugin// --- Create Reminder Endpoint ---
fastify.post('/', {
schema: {
description: 'Schedule a new SMS reminder.',
tags: ['Reminders'],
summary: 'Create Reminder',
body: ReminderCreateSchema,
response: {
201: Type.Object({
message: Type.String(),
reminderId: Type.Integer(),
}),
400: ErrorSchema, // Bad Request (validation failed)500: ErrorSchema, // Internal Server Error
},
},
}, async (request, reply) => {
const { phoneNumber, message, reminderTime } = request.bodyasReminderCreate;
const { log } = request; // Access request-specific logger// Basic validation: Ensure reminderTime is in the futureif (newDate(reminderTime) <= newDate()) {
log.warn({ reminderTime }, 'Attempt to schedule reminder in the past or present.');
reply.code(400);
return {
statusCode: 400,
error: 'Bad Request',
message: 'Reminder time must be in the future.',
};
}
try {
const stmt = db.prepare(`
INSERT INTO reminders (phoneNumber, message, reminderTime)
VALUES (?, ?, ?)
`);
const info = stmt.run(phoneNumber, message, reminderTime);
log.info({ reminderId: info.lastInsertRowid }, 'Reminder created successfully.');
reply.code(201); // Createdreturn {
message: 'Reminder scheduled successfully.',
reminderId: info.lastInsertRowid,
};
} catch (err) {
log.error({ err }, 'Error creating reminder in database.');
reply.code(500);
return {
statusCode: 500,
error: 'Internal Server Error',
message: 'Failed to schedule reminder.',
};
}
});
// --- Get Reminder by ID Endpoint ---
fastify.get('/:id', {
schema: {
description: 'Retrieve a specific reminder by its ID.',
tags: ['Reminders'],
summary: 'Get Reminder',
params: ParamsSchema,
response: {
200: ReminderResponseSchema,
404: ErrorSchema, // Not Found500: ErrorSchema,
},
},
}, async (request, reply) => {
const { id } = request.paramsasParamsType;
const { log } = request;
try {
const stmt = db.prepare('SELECT * FROM reminders WHERE id = ?');
const reminder = stmt.get(id);
if (!reminder) {
log.warn({ reminderId: id }, 'Reminder not found.');
reply.code(404);
return {
statusCode: 404,
error: 'Not Found',
message: `Reminder with ID ${id} not found.`,
};
}
log.info({ reminderId: id }, 'Reminder retrieved successfully.');
return reminder; // Automatically serialized by Fastify
} catch (err) {
log.error({ err, reminderId: id }, 'Error retrieving reminder from database.');
reply.code(500);
return {
statusCode: 500,
error: 'Internal Server Error',
message: 'Failed to retrieve reminder.',
};
}
});
// --- Delete Reminder Endpoint ---
fastify.delete('/:id', {
schema: {
description: 'Cancel/delete a pending reminder by its ID. Only pending reminders can be deleted.',
tags: ['Reminders'],
summary: 'Delete Reminder',
params: ParamsSchema,
response: {
200: Type.Object({ message: Type.String() }),
404: ErrorSchema, // Not Found or not pending500: ErrorSchema,
},
},
}, async (request, reply) => {
const { id } = request.paramsasParamsType;
const { log } = request;
try {
// Only allow deleting reminders that are still 'pending'const stmt = db.prepare(`
DELETE FROM reminders
WHERE id = ? AND status = 'pending'
`);
const info = stmt.run(id);
if (info.changes === 0) {
// Check if it exists at all to give a more specific errorconst checkStmt = db.prepare('SELECT status FROM reminders WHERE id = ?');
const existing = checkStmt.get(id);
if (!existing) {
log.warn({ reminderId: id }, 'Attempted to delete non-existent reminder.');
reply.code(404);
return { statusCode: 404, error: 'Not Found', message: `Reminder with ID ${id} not found.` };
} else {
log.warn({ reminderId: id, status: existing.status }, 'Attempted to delete reminder not in pending state.');
reply.code(400); // Bad Request might be more appropriate than 404 herereturn { statusCode: 400, error: 'Bad Request', message: `Reminder with ID ${id} cannot be deleted (status: ${existing.status}). Only pending reminders can be deleted.` };
}
}
log.info({ reminderId: id }, 'Reminder deleted successfully.');
return { message: `Reminder with ID ${id} deleted successfully.` };
} catch (err) {
log.error({ err, reminderId: id }, 'Error deleting reminder from database.');
reply.code(500);
return {
statusCode: 500,
error: 'Internal Server Error',
message: 'Failed to delete reminder.',
};
}
});
// --- List Reminders Endpoint (Optional - Add Pagination/Filtering as needed) ---
fastify.get('/', {
schema: {
description: 'List existing reminders (basic implementation).',
tags: ['Reminders'],
summary: 'List Reminders',
// Add query parameters for pagination, filtering by status, etc. hereresponse: {
200: Type.Array(ReminderResponseSchema),
500: ErrorSchema,
},
},
}, async (request, reply) => {
const { log } = request;
try {
// Basic list, consider adding LIMIT/OFFSET for paginationconst stmt = db.prepare('SELECT * FROM reminders ORDER BY createdAt DESC LIMIT 50');
const reminders = stmt.all();
log.info(`Retrieved ${reminders.length} reminders.`);
return reminders;
} catch (err) {
log.error({ err }, 'Error listing reminders from database.');
reply.code(500);
return {
statusCode: 500,
error: 'Internal Server Error',
message: 'Failed to list reminders.',
};
}
});
}
TypeBox Schemas: Uses @sinclair/typebox for defining clear request body, parameters, and response schemas. This enables automatic validation and serialization by Fastify.
E.164 Validation: Includes a basic regex for validating the phoneNumber format (maximum 15 digits as per E.164 standard).
ISO 8601 Validation: Uses format: 'date-time' for reminderTime.
SMS Character Limit: Sets maxLength: 1600 for GSM 03.38 7-bit encoded messages per Plivo's documentation. Note: Messages containing Unicode (UCS-2 16-bit) characters have a 737-character limit.
Routes: Defines standard RESTful endpoints:
POST /: Creates a new reminder. Includes validation to ensure reminderTime is in the future. Returns 201 Created.
GET /:id: Retrieves a reminder by its ID. Returns 404 Not Found if it doesn't exist.
DELETE /:id: Deletes a reminder only if its status is 'pending'. Returns 404 Not Found or 400 Bad Request if the reminder doesn't exist or isn't pending.
GET /: Lists reminders (basic implementation, limited to 50). Real applications should add pagination and filtering.
Error Handling: Each route includes try...catch blocks to handle database errors and returns appropriate HTTP status codes (400, 404, 500) with structured error messages.
Logging: Uses the request-specific logger (request.log) for contextual logging within handlers.
Database Access: Uses the fastify.db instance injected by the plugin and prepared statements for database interactions.
Frequently asked questions
You can schedule SMS reminders using Node.js with Fastify, Plivo, and a scheduler like Toad Scheduler. The process involves setting up API endpoints to accept reminder details, storing them in a database, and using a scheduled task to periodically check and send due reminders via the Plivo API.
Plivo is a cloud communications platform that provides the SMS API for sending the actual messages. The Node.js application interacts with Plivo's API using the Plivo Node.js SDK, which handles sending messages to recipients at the scheduled times.
Fastify is a high-performance Node.js web framework known for its speed and extensibility. It offers built-in features for validation, logging, and plugin support, which simplifies development and improves the maintainability of the SMS reminder application.
While SQLite is suitable for smaller projects or this tutorial, you should consider using PostgreSQL or MySQL for larger-scale applications with more significant data storage needs. SQLite can become a bottleneck as data volume and concurrency increase.
Toad-scheduler, accessed via the @fastify/schedule plugin, is responsible for scheduling the task that checks for and sends due reminders. It allows defining simple interval-based or more complex cron-like schedules to trigger the reminder sending process periodically.
The provided code includes retry logic with exponential backoff specifically for 5xx server errors or network issues when interacting with the Plivo API. This ensures that temporary errors don't permanently prevent reminders from being sent.
Optimistic locking is a strategy to prevent race conditions when multiple instances of the scheduled task (or separate processes) might try to send the same reminder. It involves updating the reminder status to "sending" with a conditional check, ensuring that the update only happens if the status is still "pending".
The application avoids sending duplicate SMS messages by using optimistic locking. Before attempting to send, the task checks if the reminder status is "pending". If the status is already "sending", it skips the reminder and moves to the next one.
Environment variables are managed using a .env file in the root directory. Create a file named .env and add your Plivo Auth ID, Auth Token, Sender ID (Plivo phone number), and any other settings there. Ensure this .env file is NOT committed to version control.
better-sqlite3 is a fast and reliable Node.js client for interacting with SQLite databases. It's used to store reminder data, including recipient number, message content, and scheduled delivery time.
To send SMS using Plivo's Node.js SDK, initialize a Plivo Client with your credentials. Then, use client.messages.create(senderId, recipientNumber, messageText) to queue a message. The code example also demonstrates best practices for retries and error handling.
To run in development mode, use the command "npm run dev". This utilizes nodemon to automatically restart the server on file changes and pino-pretty to format logs in a readable way.
You can locate your Plivo Auth ID and Auth Token on the Plivo Console dashboard at https://console.plivo.com/dashboard/. These credentials are required to authenticate with the Plivo API for sending SMS messages.
This guide provides a complete walkthrough for building a production-ready SMS scheduling and reminder application using Node.js, the Fastify web framework, and the Plivo messaging API. You'll learn how to set up the project, schedule tasks, interact with a database, send SMS messages via Plivo, and handle common production concerns like error handling, security, and deployment.
By the end of this tutorial, you will have a functional application capable of accepting requests to schedule SMS reminders, storing them, and automatically sending them out at the specified time using Plivo.
Project Overview and Goals
What We're Building:
We are creating a backend service that exposes an API to schedule SMS messages (reminders). The service will:
Problem Solved:
This system automates the process of sending timely SMS notifications or reminders, crucial for appointment confirmations, event alerts, subscription renewals, task deadlines, and more, without manual intervention.
Technologies Used:
@fastify/schedule(toad-scheduler): A Fastify plugin for scheduling recurring or one-off tasks within the application, used here to periodically check for due reminders.better-sqlite3: A simple, fast, and reliable SQLite3 client for Node.js, suitable for storing reminder data in this guide. For larger scale, consider PostgreSQL or MySQL.dotenv: For managing environment variables securely.pino-pretty: For development-friendly logging output.System Architecture:
Prerequisites:
curlor Postman for API testing.1. Setting up the Project
Let's initialize the project, install dependencies, and structure the application.
1. Create Project Directory:
Open your terminal and run:
mkdir fastify-plivo-scheduler cd fastify-plivo-scheduler2. Initialize Node.js Project:
This creates a
package.jsonfile.3. Install Dependencies:
fastify: The core web framework.@fastify/schedule: Fastify plugin for task scheduling.toad-scheduler: The underlying robust scheduling library.plivo: Plivo Node.js SDK for sending SMS.better-sqlite3: SQLite database driver.fastify-plugin: Utility for creating reusable Fastify plugins.dotenv: Loads environment variables from a.envfile.pino-pretty: Formats Fastify's logs nicely during development.@sinclair/typebox: TypeScript type builder for JSON Schema validation used in route definitions.4. Install Development Dependencies:
nodemon: Automatically restarts the server during development when file changes are detected.5. Configure
package.jsonScripts:Open
package.jsonand add/modify thescriptssection:// package.json { // ... other configurations ... "type": "module", // Enable ES Modules "scripts": { "start": "node src/app.js", "dev": "nodemon src/app.js | pino-pretty" }, // ... other configurations ... }"type": "module": Enables the use of ES Module syntax (import/export).start: Runs the application in production mode.dev: Runs the application in development mode usingnodemonfor auto-restarts andpino-prettyfor readable logs.6. Create Project Structure:
Create the following directories and files:
7. Create
.gitignore:Create a
.gitignorefile in the root directory to prevent sensitive files and unnecessary directories from being committed to version control:8. Configure Environment Variables (
.env):Create a
.envfile in the root directory. Important: Replace the placeholder values (YOUR_PLIVO_AUTH_ID,YOUR_PLIVO_AUTH_TOKEN,YOUR_PLIVO_PHONE_NUMBER) with your actual credentials obtained from your Plivo Console.PORT,HOST: Network configuration for the Fastify server.NODE_ENV: Determines environment-specific settings (e.g., logging).LOG_LEVEL: Controls the verbosity of logs.DATABASE_FILE: Path to the SQLite database file.PLIVO_AUTH_ID,PLIVO_AUTH_TOKEN: Replace these with your Plivo API credentials found on the Plivo Console dashboard.PLIVO_SENDER_ID: Replace this with an SMS-enabled Plivo phone number you've purchased, found under Phone Numbers > Your Numbers on the Plivo Console. It must be in E.164 format (international standard for telephone numbering, limited to 15 digits including country code).9. Basic Logger Configuration:
Create
src/config/logger.js:// src/config/logger.js import pino from 'pino'; const isProduction = process.env.NODE_ENV === 'production'; const loggerConfig = { level: process.env.LOG_LEVEL || 'info', ...(isProduction ? {} : { transport: { target: 'pino-pretty' } }), // Use pino-pretty only in dev }; export default pino(loggerConfig);This configures the Pino logger, using
pino-prettyfor readable logs in development and standard JSON logs in production.10. Basic Fastify App Setup:
Create
src/app.js:// src/app.js import Fastify from 'fastify'; import dotenv from 'dotenv'; import logger from './config/logger.js'; import dbPlugin from './plugins/db.js'; import schedulerPlugin from './plugins/scheduler.js'; import reminderRoutes from './routes/reminders.js'; // Load environment variables dotenv.config(); // --- Helper Function to Load Environment Variables --- function loadEnv(varName, required = true, defaultValue = null) { const value = process.env[varName]; if (required && !value) { logger.fatal(`Missing required environment variable: ${varName}`); process.exit(1); } return value || defaultValue; } // --- Initialize Fastify --- const fastify = Fastify({ logger: logger, // Use our configured logger }); async function buildApp() { // --- Register Plugins --- // Decorates fastify instance with 'db' await fastify.register(dbPlugin, { dbFile: loadEnv('DATABASE_FILE'), }); fastify.log.info('Database plugin registered'); // Decorates fastify instance with 'scheduler' and loads tasks await fastify.register(schedulerPlugin, { plivoAuthId: loadEnv('PLIVO_AUTH_ID'), plivoAuthToken: loadEnv('PLIVO_AUTH_TOKEN'), plivoSenderId: loadEnv('PLIVO_SENDER_ID'), }); fastify.log.info('Scheduler plugin registered'); // --- Register Routes --- await fastify.register(reminderRoutes, { prefix: '/api/v1' }); fastify.log.info('Reminder routes registered under /api/v1'); // --- Simple Health Check Route --- fastify.get('/health', async (request, reply) => { return { status: 'ok', timestamp: new Date().toISOString() }; }); return fastify; } // --- Start the Server --- async function start() { try { const app = await buildApp(); const port = parseInt(loadEnv('PORT', false, '3000'), 10); const host = loadEnv('HOST', false, '0.0.0.0'); await app.listen({ port, host }); app.log.info(`Server environment is ${process.env.NODE_ENV}`); // Ready signal is logged by Fastify on successful listen } catch (err) { logger.fatal({ err }, 'Failed to start server'); process.exit(1); } } start();This sets up the basic Fastify application:
dotenv.loadEnvfunction for validation.buildAppfunction to register plugins and routes (we'll create these next)./healthcheck endpoint.Now you have a basic project structure and setup ready. Run
npm run devin your terminal. You should see log output indicating the server has started, listening on port 3000. You can accesshttp://localhost:3000/healthin your browser or viacurlto verify.2. Creating a Database Schema and Data Layer (Combined with Plugin Setup)
We'll set up the SQLite database and create the necessary table using a Fastify plugin.
1. Implement the Database Plugin (
src/plugins/db.js):// src/plugins/db.js import fp from 'fastify-plugin'; import Database from 'better-sqlite3'; async function dbConnector(fastify, options) { const { dbFile } = options; if (!dbFile) { throw new Error('Database file path (dbFile) is required for dbPlugin'); } try { // Connect to SQLite database. Creates the file if it doesn't exist. const db = new Database(dbFile, { // verbose: fastify.log.debug // Uncomment for detailed DB logging }); fastify.log.info(`Connected to database: ${dbFile}`); // --- Define Schema and Create Table --- // Use TEXT for ISO 8601 date strings (UTC recommended) // status: pending, sending, sent, failed const createTableStmt = ` CREATE TABLE IF NOT EXISTS reminders ( id INTEGER PRIMARY KEY AUTOINCREMENT, phoneNumber TEXT NOT NULL, message TEXT NOT NULL, reminderTime TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', createdAt TEXT DEFAULT CURRENT_TIMESTAMP, updatedAt TEXT DEFAULT CURRENT_TIMESTAMP, plivoMessageUuid TEXT NULL ); `; db.exec(createTableStmt); fastify.log.info('Ensured "reminders" table exists.'); // Add triggers for updatedAt (optional but good practice) const createTriggerStmt = ` CREATE TRIGGER IF NOT EXISTS update_reminders_updatedAt AFTER UPDATE ON reminders FOR EACH ROW BEGIN UPDATE reminders SET updatedAt = CURRENT_TIMESTAMP WHERE id = OLD.id; END; `; db.exec(createTriggerStmt); fastify.log.info('Ensured "update_reminders_updatedAt" trigger exists.'); // --- Decorate Fastify Instance --- // Make the 'db' instance available throughout the application fastify.decorate('db', db); // --- Cleanup Hook --- // Ensure the database connection is closed gracefully on server shutdown fastify.addHook('onClose', (instance, done) => { if (instance.db && instance.db.open) { instance.db.close(); instance.log.info('Database connection closed.'); } done(); }); } catch (err) { fastify.log.error({ err }, 'Failed to connect to or initialize the database'); // Allow the error to propagate up to stop the server start throw err; } } // Export the plugin using fastify-plugin to avoid encapsulation issues // and make the decorator ('db') available globally. export default fp(dbConnector, { name: 'db-connector', // Optional name for the plugin fastify: '4-5.x' // Specify Fastify version compatibility (v4 and v5) });fastify-plugin(fp) to make thedbdecorator globally available.app.js).reminderstable SQL.id: Primary key.phoneNumber: Recipient number (E.164 format).message: SMS content.reminderTime: Scheduled time in ISO 8601 format (UTC recommended). Storing asTEXT.status: Tracks the reminder state ('pending', 'sending', 'sent', 'failed').createdAt,updatedAt: Timestamps.plivoMessageUuid: Stores the ID returned by Plivo upon successful sending, useful for tracking.db.exec()runs theCREATE TABLE IF NOT EXISTSstatement, making it idempotent.updatedAton row updates.fastify.decorate('db', db)makes the database connection accessible viafastify.dborrequest.server.dbin handlers and other plugins.onClosehook ensures the database connection is closed when the Fastify server stops.try...catchblock to handle connection/initialization errors.2. Data Access:
With the plugin registered in
app.js, you can now access the database in your route handlers (created later) like this:// Example usage in a route handler (src/routes/reminders.js) async function createReminderHandler(request, reply) { const { db } = request.server; // Access the decorated DB instance const { phoneNumber, message, reminderTime } = request.body; try { const stmt = db.prepare('INSERT INTO reminders (phoneNumber, message, reminderTime) VALUES (?, ?, ?)'); const info = stmt.run(phoneNumber, message, reminderTime); // ... handle response ... } catch (err) { // ... handle error ... } }We use prepared statements (
db.prepare(...)) which automatically sanitize inputs, preventing SQL injection vulnerabilities.3. Integrating with Necessary Third-Party Services (Plivo)
Now, let's create a service module to handle interactions with the Plivo API.
1. Create Plivo Service (
src/services/plivoService.js):// src/services/plivoService.js import { Client } from 'plivo'; // Use named import for ES Modules import { setTimeout } from 'node:timers/promises'; // For async delay class PlivoService { constructor(authId, authToken, senderId, logger) { if (!authId || !authToken || !senderId) { throw new Error('PlivoService requires authId, authToken, and senderId'); } // Ensure logger is provided, default to console if not this.logger = logger || console; try { this.client = new Client(authId, authToken); this.senderId = senderId; this.logger.info('Plivo client initialized successfully.'); } catch (error) { this.logger.error({ err: error }, 'Failed to initialize Plivo client'); throw error; // Re-throw to prevent service usage with bad config } } /** * Sends an SMS message using the Plivo API with retry logic. * @param {string} to - The recipient phone number in E.164 format. * @param {string} text - The message content. * @returns {Promise<object|null>} Plivo API response on success, null on failure after retries. */ async sendSms(to, text) { this.logger.info({ to }, `Attempting to send SMS`); if (!this.client) { this.logger.error('Plivo client not initialized. Cannot send SMS.'); return null; } const maxRetries = 2; // Retry up to 2 times (total 3 attempts) const initialDelay = 500; // Start with 500ms delay for (let attempt = 1; attempt <= maxRetries + 1; attempt++) { try { const response = await this.client.messages.create( this.senderId, // Sender's phone number (from config) to, // Receiver's phone number text // The content of the message ); this.logger.info({ response, to, attempt }, 'SMS sent successfully via Plivo.'); // Example response: { apiId: '...', message: 'message(s) queued', messageUuid: [ '...' ] } return response; // Success, exit loop } catch (error) { this.logger.warn({ err: { message: error.message, statusCode: error.statusCode }, to, attempt, maxRetries }, `Attempt ${attempt} failed sending SMS via Plivo.`); // Only retry on specific conditions (e.g., server errors, rate limits if desired) // Plivo error codes: https://www.plivo.com/docs/messaging/troubleshooting/error-codes // Generally retry on 5xx status codes from Plivo or network errors. // Avoid retrying on 4xx errors (like invalid number) as they won't succeed. const shouldRetry = (error.statusCode && error.statusCode >= 500) || (error.message && (error.message.includes('ETIMEDOUT') || error.message.includes('ECONNRESET'))); // Example network errors if (shouldRetry && attempt <= maxRetries) { const delay = initialDelay * Math.pow(2, attempt - 1); // Exponential backoff this.logger.info(`Retrying in ${delay}ms...`); await setTimeout(delay); // Use async setTimeout } else { // Don't retry or max retries reached, log final error and exit this.logger.error({ err: { message: error.message, statusCode: error.statusCode, feedback: error.feedback, error: error.error }, to }, 'Error sending SMS via Plivo (final attempt or non-retriable error).'); return null; // Indicate final failure } } } // This line should only be reached if the loop finishes without returning (which indicates a failure after retries) return null; } } export default PlivoService;authId,authToken), thesenderId(your Plivo number), and a logger instance. It initializes the PlivoClient. Includes robust error handling for initialization failures.sendSmsMethod (with Retries):to) and messagetext.for) with exponential backoff (initialDelay * Math.pow(2, attempt - 1)) usingnode:timers/promises.setTimeout.this.client.messages.create().ETIMEDOUT,ECONNRESET). Does not retry on 4xx errors (like invalid number) as they won't succeed.nullafter exhausting retries or encountering a non-retriable error.2. Integrate into Scheduler Plugin (Update
src/plugins/scheduler.js):We need to instantiate
PlivoServiceand make it available to our scheduled task.// src/plugins/scheduler.js import fp from 'fastify-plugin'; import { fastifySchedule } from '@fastify/schedule'; import { SimpleIntervalJob } from 'toad-scheduler'; import PlivoService from '../services/plivoService.js'; // Import the service import createSendRemindersTask from '../tasks/sendRemindersTask.js'; // Import the task factory async function schedulerPlugin(fastify, options) { const { plivoAuthId, plivoAuthToken, plivoSenderId } = options; // --- Instantiate Plivo Service --- // Pass credentials and the fastify logger to the service const plivoService = new PlivoService(plivoAuthId, plivoAuthToken, plivoSenderId, fastify.log); // --- Register @fastify/schedule --- await fastify.register(fastifySchedule); fastify.log.info('@fastify/schedule registered.'); // --- Create and Add Scheduled Task --- // Ensure the scheduler is ready before adding jobs fastify.ready().then(() => { fastify.log.info('Fastify instance is ready, setting up scheduled jobs.'); // Check for due reminders every 30 seconds (adjust as needed) const intervalInSeconds = 30; // Create the task, injecting dependencies (db, plivoService, logger) const task = createSendRemindersTask(fastify.db, plivoService, fastify.log); // Create the job configuration const job = new SimpleIntervalJob( { seconds: intervalInSeconds, runImmediately: true }, // Run immediately on start, then every interval task, // The async task function to run { id: 'send-reminders-job', preventOverrun: true } // Unique ID, prevent task overlap ); // Add the job to the scheduler try { fastify.scheduler.addSimpleIntervalJob(job); fastify.log.info(`Scheduled job "${job.id}" to run every ${intervalInSeconds} seconds.`); } catch (err) { fastify.log.error({ err, jobId: job.id }, 'Failed to add scheduled job'); // Depending on severity, you might want to process.exit(1) here } }).catch(err => { fastify.log.error({ err }, 'Error during fastify.ready() in scheduler plugin'); }); // No decoration needed here, scheduler is accessed via fastify.scheduler } export default fp(schedulerPlugin, { name: 'scheduler-plugin', dependencies: ['db-connector'], // Ensure DB is ready before scheduler fastify: '4-5.x' });PlivoServiceand Task Factory: Import the necessary modules.PlivoService: Create an instance, passing credentials and the Fastify logger (fastify.log).fastify.db, theplivoServiceinstance, andfastify.logto the task factory function (createSendRemindersTask). This makes them available within the task's logic.'db-connector'todependenciesto ensure the database plugin runs first.4. Implementing Core Functionality (The Reminder Task)
This task runs periodically, finds due reminders, and triggers sending them via the
PlivoService.1. Create the Send Reminders Task (
src/tasks/sendRemindersTask.js):// src/tasks/sendRemindersTask.js import { AsyncTask } from 'toad-scheduler'; // Factory function to create the task with injected dependencies function createSendRemindersTask(db, plivoService, logger) { // The actual task logic, returned as an AsyncTask const taskLogic = async () => { logger.info('Running sendRemindersTask...'); const now = new Date().toISOString(); // Get current time in UTC ISO 8601 format let remindersToSend = []; try { // --- Find Due Reminders --- // Select reminders that are 'pending' and whose time is now or in the past const stmt = db.prepare(` SELECT id, phoneNumber, message FROM reminders WHERE status = 'pending' AND reminderTime <= ? ORDER BY reminderTime ASC LIMIT 10 -- Process in batches to avoid overwhelming resources `); remindersToSend = stmt.all(now); logger.info(`Found ${remindersToSend.length} reminders due for sending.`); } catch (err) { logger.error({ err }, 'Error fetching pending reminders from database.'); return; // Exit task if DB query fails } if (remindersToSend.length === 0) { logger.info('No pending reminders to send at this time.'); return; // Nothing to do } // --- Process Each Reminder --- for (const reminder of remindersToSend) { const { id, phoneNumber, message } = reminder; logger.info({ reminderId: id }, 'Processing reminder.'); try { // --- Mark as Sending (Optimistic Locking) --- // Prevents duplicate sends if the task runs again before completion const updateSendingStmt = db.prepare(` UPDATE reminders SET status = 'sending', updatedAt = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending' -- Ensure it's still pending `); const updateResult = updateSendingStmt.run(id); if (updateResult.changes === 0) { // Another process might have picked it up already logger.warn({ reminderId: id }, 'Reminder was not in pending state when attempting to mark as sending. Skipping.'); continue; // Skip to the next reminder } logger.info({ reminderId: id }, 'Marked reminder as "sending".'); // --- Send SMS via Plivo Service --- const plivoResponse = await plivoService.sendSms(phoneNumber, message); // --- Update Status Based on Plivo Response --- let finalStatus = 'failed'; // Default to failed let plivoUuid = null; if (plivoResponse && plivoResponse.messageUuid && plivoResponse.messageUuid.length > 0) { finalStatus = 'sent'; plivoUuid = Array.isArray(plivoResponse.messageUuid) ? plivoResponse.messageUuid[0] : plivoResponse.messageUuid; // Plivo might return array logger.info({ reminderId: id, plivoMessageUuid: plivoUuid }, 'Reminder successfully sent.'); } else { logger.error({ reminderId: id }, 'Failed to send reminder via Plivo (PlivoService indicated failure).'); } const updateStatusStmt = db.prepare(` UPDATE reminders SET status = ?, plivoMessageUuid = ?, updatedAt = CURRENT_TIMESTAMP WHERE id = ? `); updateStatusStmt.run(finalStatus, plivoUuid, id); logger.info({ reminderId: id, status: finalStatus }, 'Updated final reminder status in database.'); } catch (err) { logger.error({ err, reminderId: id }, 'Unhandled error processing reminder. Attempting to mark as failed.'); // Attempt to mark as failed even if sending failed partway through try { // Use standard quotes for consistency if preferred, or backticks as originally shown const failStmt = db.prepare(`UPDATE reminders SET status = 'failed', updatedAt = CURRENT_TIMESTAMP WHERE id = ? AND status != 'sent'`); failStmt.run(id); } catch (dbErr) { logger.error({ err: dbErr, reminderId: id }, 'Failed to mark reminder as failed after processing error.'); } } } // End for loop logger.info('sendRemindersTask finished processing batch.'); }; // End taskLogic // --- Error Handling Wrapper for the Task --- const errorHandler = (err) => { logger.error({ err }, 'Fatal error occurred within the scheduled AsyncTask (sendRemindersTask).'); // Consider more robust error reporting here (e.g., Sentry, Better Stack) }; // --- Return the AsyncTask --- // Wrap the logic in toad-scheduler's AsyncTask for proper handling return new AsyncTask('send-reminders-task', taskLogic, errorHandler); } export default createSendRemindersTask;createSendRemindersTaskto accept dependencies (db,plivoService,logger) via injection. This improves testability.AsyncTask: The core logic is wrapped intoad-scheduler'sAsyncTaskwhich provides structured execution and error handling.reminderstable for entries withstatus = 'pending'andreminderTime <= now().LIMITto process reminders in batches, preventing the task from holding resources for too long if there are many due reminders.reminderTimeto process older ones first.statusto'sending'.WHERE id = ? AND status = 'pending'clause ensures that only one instance of the task (or another process) can successfully claim a specific reminder. IfupdateResult.changesis 0, it means the status was already changed, so the current task skips it.plivoService.sendSmsmethod (which now includes retry logic).plivoResponse, it updates the reminderstatusto'sent'or'failed'.plivoMessageUuidif the message was sent successfully.try...catchblocks for database operations and the Plivo call. Attempts to mark the reminder as'failed'if an error occurs during processing.errorHandlerfunction passed toAsyncTaskcatches errors originating directly from thetaskLogicexecution itself (though internal try/catches handle most specific cases).5. Building a Complete API Layer
Let's define the API endpoints for managing reminders.
1. Create Reminder Routes (
src/routes/reminders.js):// src/routes/reminders.js import { Static, Type } from '@sinclair/typebox'; // For schema validation // --- Request/Response Schemas using TypeBox --- // E.164 regex (simplified version, adjust for stricter needs) const E164Format = /^\+[1-9]\d{1,14}$/; const ReminderBaseSchema = Type.Object({ phoneNumber: Type.RegExp(E164Format, { description: 'Recipient phone number in E.164 format (e.g., +14155551234)', examples: ['+14155551234'], }), message: Type.String({ minLength: 1, maxLength: 1600, // Plivo limit for GSM 03.38 7-bit characters (https://support.plivo.com/hc/en-us/articles/360041298072) description: 'The content of the SMS message', }), // ISO 8601 format (YYYY-MM-DDTHH:mm:ssZ or YYYY-MM-DDTHH:mm:ss+HH:mm) reminderTime: Type.String({ format: 'date-time', description: 'Scheduled time in ISO 8601 format (UTC recommended)', examples: ['2025-12-31T23:59:59Z'], }), }); const ReminderCreateSchema = ReminderBaseSchema; const ReminderResponseSchema = Type.Intersect([ Type.Object({ id: Type.Integer({ description: 'Unique identifier for the reminder' }), status: Type.String({ description: 'Current status (pending, sending, sent, failed)' }), createdAt: Type.String({ format: 'date-time', description: 'Timestamp when the reminder was created' }), updatedAt: Type.String({ format: 'date-time', description: 'Timestamp when the reminder was last updated' }), plivoMessageUuid: Type.Union([Type.String(), Type.Null()], { description: 'Plivo message ID if sent, otherwise null' }), }), ReminderBaseSchema, // Include base fields ]); // Type definition for TypeScript users (optional but good practice) type ReminderCreate = Static<typeof ReminderCreateSchema>; // type ReminderResponse = Static<typeof ReminderResponseSchema>; // If needed const ParamsSchema = Type.Object({ id: Type.Integer({ description: 'Reminder ID' }), }); type ParamsType = Static<typeof ParamsSchema>; const ErrorSchema = Type.Object({ statusCode: Type.Integer(), error: Type.String(), message: Type.String(), }); // --- Route Definitions --- export default async function reminderRoutes(fastify, options) { const { db } = fastify; // Access db instance decorated by the plugin // --- Create Reminder Endpoint --- fastify.post('/', { schema: { description: 'Schedule a new SMS reminder.', tags: ['Reminders'], summary: 'Create Reminder', body: ReminderCreateSchema, response: { 201: Type.Object({ message: Type.String(), reminderId: Type.Integer(), }), 400: ErrorSchema, // Bad Request (validation failed) 500: ErrorSchema, // Internal Server Error }, }, }, async (request, reply) => { const { phoneNumber, message, reminderTime } = request.body as ReminderCreate; const { log } = request; // Access request-specific logger // Basic validation: Ensure reminderTime is in the future if (new Date(reminderTime) <= new Date()) { log.warn({ reminderTime }, 'Attempt to schedule reminder in the past or present.'); reply.code(400); return { statusCode: 400, error: 'Bad Request', message: 'Reminder time must be in the future.', }; } try { const stmt = db.prepare(` INSERT INTO reminders (phoneNumber, message, reminderTime) VALUES (?, ?, ?) `); const info = stmt.run(phoneNumber, message, reminderTime); log.info({ reminderId: info.lastInsertRowid }, 'Reminder created successfully.'); reply.code(201); // Created return { message: 'Reminder scheduled successfully.', reminderId: info.lastInsertRowid, }; } catch (err) { log.error({ err }, 'Error creating reminder in database.'); reply.code(500); return { statusCode: 500, error: 'Internal Server Error', message: 'Failed to schedule reminder.', }; } }); // --- Get Reminder by ID Endpoint --- fastify.get('/:id', { schema: { description: 'Retrieve a specific reminder by its ID.', tags: ['Reminders'], summary: 'Get Reminder', params: ParamsSchema, response: { 200: ReminderResponseSchema, 404: ErrorSchema, // Not Found 500: ErrorSchema, }, }, }, async (request, reply) => { const { id } = request.params as ParamsType; const { log } = request; try { const stmt = db.prepare('SELECT * FROM reminders WHERE id = ?'); const reminder = stmt.get(id); if (!reminder) { log.warn({ reminderId: id }, 'Reminder not found.'); reply.code(404); return { statusCode: 404, error: 'Not Found', message: `Reminder with ID ${id} not found.`, }; } log.info({ reminderId: id }, 'Reminder retrieved successfully.'); return reminder; // Automatically serialized by Fastify } catch (err) { log.error({ err, reminderId: id }, 'Error retrieving reminder from database.'); reply.code(500); return { statusCode: 500, error: 'Internal Server Error', message: 'Failed to retrieve reminder.', }; } }); // --- Delete Reminder Endpoint --- fastify.delete('/:id', { schema: { description: 'Cancel/delete a pending reminder by its ID. Only pending reminders can be deleted.', tags: ['Reminders'], summary: 'Delete Reminder', params: ParamsSchema, response: { 200: Type.Object({ message: Type.String() }), 404: ErrorSchema, // Not Found or not pending 500: ErrorSchema, }, }, }, async (request, reply) => { const { id } = request.params as ParamsType; const { log } = request; try { // Only allow deleting reminders that are still 'pending' const stmt = db.prepare(` DELETE FROM reminders WHERE id = ? AND status = 'pending' `); const info = stmt.run(id); if (info.changes === 0) { // Check if it exists at all to give a more specific error const checkStmt = db.prepare('SELECT status FROM reminders WHERE id = ?'); const existing = checkStmt.get(id); if (!existing) { log.warn({ reminderId: id }, 'Attempted to delete non-existent reminder.'); reply.code(404); return { statusCode: 404, error: 'Not Found', message: `Reminder with ID ${id} not found.` }; } else { log.warn({ reminderId: id, status: existing.status }, 'Attempted to delete reminder not in pending state.'); reply.code(400); // Bad Request might be more appropriate than 404 here return { statusCode: 400, error: 'Bad Request', message: `Reminder with ID ${id} cannot be deleted (status: ${existing.status}). Only pending reminders can be deleted.` }; } } log.info({ reminderId: id }, 'Reminder deleted successfully.'); return { message: `Reminder with ID ${id} deleted successfully.` }; } catch (err) { log.error({ err, reminderId: id }, 'Error deleting reminder from database.'); reply.code(500); return { statusCode: 500, error: 'Internal Server Error', message: 'Failed to delete reminder.', }; } }); // --- List Reminders Endpoint (Optional - Add Pagination/Filtering as needed) --- fastify.get('/', { schema: { description: 'List existing reminders (basic implementation).', tags: ['Reminders'], summary: 'List Reminders', // Add query parameters for pagination, filtering by status, etc. here response: { 200: Type.Array(ReminderResponseSchema), 500: ErrorSchema, }, }, }, async (request, reply) => { const { log } = request; try { // Basic list, consider adding LIMIT/OFFSET for pagination const stmt = db.prepare('SELECT * FROM reminders ORDER BY createdAt DESC LIMIT 50'); const reminders = stmt.all(); log.info(`Retrieved ${reminders.length} reminders.`); return reminders; } catch (err) { log.error({ err }, 'Error listing reminders from database.'); reply.code(500); return { statusCode: 500, error: 'Internal Server Error', message: 'Failed to list reminders.', }; } }); }@sinclair/typeboxfor defining clear request body, parameters, and response schemas. This enables automatic validation and serialization by Fastify.phoneNumberformat (maximum 15 digits as per E.164 standard).format: 'date-time'forreminderTime.maxLength: 1600for GSM 03.38 7-bit encoded messages per Plivo's documentation. Note: Messages containing Unicode (UCS-2 16-bit) characters have a 737-character limit.POST /: Creates a new reminder. Includes validation to ensurereminderTimeis in the future. Returns201 Created.GET /:id: Retrieves a reminder by its ID. Returns404 Not Foundif it doesn't exist.DELETE /:id: Deletes a reminder only if its status is'pending'. Returns404 Not Foundor400 Bad Requestif the reminder doesn't exist or isn't pending.GET /: Lists reminders (basic implementation, limited to 50). Real applications should add pagination and filtering.try...catchblocks to handle database errors and returns appropriate HTTP status codes (400, 404, 500) with structured error messages.request.log) for contextual logging within handlers.fastify.dbinstance injected by the plugin and prepared statements for database interactions.