code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / Plivo

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.

SMS Scheduling with Plivo, Node.js & Fastify: Complete Tutorial

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:

  1. Accept API requests specifying a recipient phone number, a message body, and a future delivery time.
  2. Store these scheduled messages in a database.
  3. Periodically check the database for messages scheduled to be sent.
  4. Use the Plivo API to send the messages at their scheduled time.
  5. 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:

mermaid
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

2. Initialize Node.js Project:

bash
npm init -y

This creates a package.json file.

3. Install Dependencies:

bash
npm install fastify @fastify/schedule toad-scheduler plivo better-sqlite3 fastify-plugin dotenv pino-pretty @sinclair/typebox
  • 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.
  • @sinclair/typebox: TypeScript type builder for JSON Schema validation used in route definitions.

4. Install Development Dependencies:

bash
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:

json
// 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 using nodemon for auto-restarts and pino-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:

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.

dotenv
# .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 (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.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:

javascript
// 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):

javascript
// 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)
});
  • 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)
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):

javascript
// 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;
  • 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.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'
});
  • 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.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 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.

1. Create Reminder Routes (src/routes/reminders.js):

javascript
// 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.',
            };
        }
    });
}
  • 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.