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, 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 (v18 or later recommended) 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:
mkdir fastify-plivo-scheduler
cd fastify-plivo-scheduler
2. Initialize Node.js Project:
npm init -y
This creates a package.json
file.
3. Install Dependencies:
npm install fastify @fastify/schedule toad-scheduler plivo better-sqlite3 fastify-plugin dotenv pino-pretty
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.env
file.pino-pretty
: Formats Fastify's logs nicely during development.
4. Install Development Dependencies:
npm install --save-dev nodemon
nodemon
: Automatically restarts the server during development when file changes are detected.
5. Configure package.json
Scripts:
Open package.json
and add/modify the scripts
section:
// 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 usingnodemon
for auto-restarts andpino-pretty
for readable logs.
6. Create Project Structure:
Create the following directories and files:
fastify-plivo-scheduler/
├── .env # Environment variables (DO NOT COMMIT)
├── .gitignore # Specifies intentionally untracked files git should ignore
├── package.json
├── package-lock.json
└── src/
├── app.js # Main application entry point
├── config/ # Configuration files (e.g., logger)
│ └── logger.js
├── plugins/ # Fastify plugins (e.g., database, scheduler)
│ ├── db.js
│ └── scheduler.js
├── routes/ # API route definitions
│ └── reminders.js
├── services/ # Business logic/external service interactions
│ └── plivoService.js
└── tasks/ # Scheduled tasks logic
└── sendRemindersTask.js
7. Create .gitignore
:
Create a .gitignore
file in the root directory to prevent sensitive files and unnecessary directories from being committed to version control:
# .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
# 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.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.
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-pretty
for 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:
- Loads environment variables using
dotenv
. - 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
):
// 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.x' // Specify Fastify version compatibility
});
- Plugin Structure: Uses
fastify-plugin
(fp
) to make thedb
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 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.
- Table Creation:
db.exec()
runs theCREATE 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 viafastify.db
orrequest.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:
// 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/sms/api/message#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;
- Class Structure: Encapsulates Plivo logic within a class.
- Constructor: Takes Plivo credentials (
authId
_authToken
)_ thesenderId
(your Plivo number)_ and a logger instance. It initializes the PlivoClient
. Includes robust error handling for initialization failures. sendSms
Method (with Retries):- Takes the recipient (
to
) and messagetext
. - Implements a retry loop (
for
) with exponential backoff (initialDelay * Math.pow(2_ attempt - 1)
) usingnode:timers/promises.setTimeout
. - Attempts to send SMS using
this.client.messages.create()
. - Retries only on specific conditions (5xx errors_ common network errors like
ETIMEDOUT
_ECONNRESET
). Does not retry on 4xx client errors. - 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.
- Takes the recipient (
2. Integrate into Scheduler Plugin (Update src/plugins/scheduler.js
):
We need to instantiate PlivoService
and 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.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
, theplivoService
instance, andfastify.log
to the task factory function (createSendRemindersTask
). This makes them available within the task's logic. - Dependency: Added
'db-connector'
todependencies
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
):
// 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;
- Factory Pattern: Uses a factory function
createSendRemindersTask
to accept dependencies (db
,plivoService
,logger
) via injection. This improves testability. AsyncTask
: The core logic is wrapped intoad-scheduler
'sAsyncTask
which provides structured execution and error handling.- Find Due Reminders:
- Queries the
reminders
table for entries withstatus = 'pending'
andreminderTime <= 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.
- Queries the
- 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. IfupdateResult.changes
is 0_ it means the status was already changed_ so the current task skips it.
- Before sending_ it updates the reminder's
- Send SMS: Calls the injected
plivoService.sendSms
method (which now includes retry logic). - Update Final Status:
- Based on the
plivoResponse
_ it updates the reminderstatus
to'sent'
or'failed'
. - Stores the
plivoMessageUuid
if the message was sent successfully.
- Based on the
- 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 toAsyncTask
catches errors originating directly from thetaskLogic
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.
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_ though carriers might vary
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.',
};
}
});
}
- 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. - ISO 8601 Validation: Uses
format: 'date-time'
forreminderTime
. - Routes: Defines standard RESTful endpoints:
POST /
: Creates a new reminder. Includes validation to ensurereminderTime
is in the future. Returns201 Created
.GET /:id
: Retrieves a reminder by its ID. Returns404 Not Found
if it doesn't exist.DELETE /:id
: Deletes a reminder only if its status is'pending'
. Returns404 Not Found
or400 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.