code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

Build a Node.js Appointment Scheduler with SMS Reminders

A step-by-step guide to creating a Node.js and Express application for scheduling appointments with automated Plivo SMS reminders.

This guide provides a step-by-step walkthrough for building a robust appointment scheduling application using Node.js and Express. The application will enable users to book appointments and automatically receive SMS reminders via the Plivo communication platform before their scheduled time.

We'll cover everything from initial project setup and database configuration to implementing core scheduling logic, integrating with Plivo, handling errors, securing the application, and deploying it.

Project Goals:

  • Create a web application allowing users to schedule appointments (name, phone number, time).
  • Implement a background process to automatically send SMS reminders via Plivo shortly before the appointment time.
  • Build a RESTful API for managing appointments.
  • Ensure the system is robust, secure, and ready for production deployment.

Technologies Used:

  • Node.js: A JavaScript runtime for building server-side applications.
  • Express: A minimal and flexible Node.js web application framework.
  • Plivo: A cloud communications platform providing SMS APIs.
  • MongoDB: A NoSQL database for storing appointment data.
  • Mongoose: An Object Data Modeling (ODM) library for MongoDB and Node.js.
  • node-cron: A simple cron-like job scheduler for Node.js.
  • dotenv: A module to load environment variables from a .env file.

System Architecture:

The system consists of the following components:

  1. Express API Server: Handles incoming HTTP requests to create appointments.
  2. MongoDB Database: Stores appointment details (name, phone number, time, reminder status).
  3. Background Scheduler (node-cron): Periodically checks the database for upcoming appointments that require reminders.
  4. Plivo Service: Interacts with the Plivo API to send SMS reminders.
text
System Flow:

[User/Client] --(HTTP POST /appointments)--> [Express API Server]
    |
    +----(Create Appointment)----> [MongoDB Database]
    +----(Read/Write Data)------> [MongoDB Database]

[Background Scheduler (node-cron)] --(Periodically Checks)--> [MongoDB Database]
    |
    +----(Finds Upcoming Appointment)--> [Plivo Service]
    |                                    |
    |                                    +--(Send SMS Request)--> [Plivo API]
    |                                                             |
    |                                                             +--(Sends SMS)--> [User's Phone]
    +----(Update Reminder Status)-----> [MongoDB Database]

Prerequisites:

  • Node.js and npm (or yarn): Installed on your system. (Download: https://nodejs.org/)
  • MongoDB: A running MongoDB instance (local or cloud-based like MongoDB Atlas).
  • Plivo Account: Sign up for a free Plivo account (https://console.plivo.com/accounts/register/).
  • Plivo Auth ID and Auth Token: Found on your Plivo Console dashboard.
  • Plivo Phone Number: An SMS-enabled phone number purchased or rented through your Plivo account. This number must be capable of sending messages to your target region(s).
  • Basic understanding of JavaScript, Node.js, Express, and REST APIs.

Final Outcome:

By the end of this guide, you will have a functional Node.js application that can:

  • Accept appointment bookings via a REST API endpoint.
  • Store appointment data securely.
  • Automatically send SMS reminders at a configurable interval before the appointment time using Plivo.
  • Be structured and configured for potential deployment.

1. Setting up the project

Let's initialize our Node.js project, install necessary dependencies, and set up the basic project structure and environment configuration.

1.1 Create Project Directory:

Open your terminal or command prompt and create a new directory for the project.

bash
mkdir plivo-scheduler-app
cd plivo-scheduler-app

1.2 Initialize Node.js Project:

Initialize the project using npm, which creates a package.json file.

bash
npm init -y
  • Why -y? This flag automatically accepts the default settings for the project initialization, speeding up the process.

1.3 Install Dependencies:

Install the core dependencies: Express for the web server, Mongoose for database interaction, Plivo's Node.js SDK, node-cron for scheduling, and dotenv for environment variables.

bash
npm install express mongoose plivo node-cron dotenv
  • express: The web framework.
  • mongoose: ODM for MongoDB, simplifying data modeling and interaction.
  • plivo: The official Plivo Node.js helper library.
  • node-cron: Task scheduler used to trigger reminder checks.
  • dotenv: Loads environment variables from a .env file into process.env.

(Optional) Install Development Dependencies:

Install nodemon for automatic server restarts during development.

bash
npm install --save-dev nodemon

Add a development script to your package.json:

json
// package.json
{
  // ... other properties
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  // ... other properties
}
  • Why nodemon? It watches for file changes in your project and automatically restarts the Node.js application, significantly improving the development workflow.

1.4 Project Structure:

Create the following directory structure within plivo-scheduler-app:

plivo-scheduler-app/ ├── config/ │ └── db.js # Database connection logic ├── controllers/ │ └── appointmentController.js # Handles request logic for appointments ├── models/ │ └── Appointment.js # Mongoose schema for appointments ├── routes/ │ └── appointments.js # Defines API routes for appointments ├── services/ │ ├── plivoService.js # Logic for interacting with Plivo API │ └── scheduler.js # Contains the node-cron job logic ├── .env # Environment variables (DO NOT COMMIT) ├── .gitignore # Specifies intentionally untracked files ├── package.json ├── package-lock.json └── server.js # Main application entry point
  • Why this structure? This separation of concerns (config, controllers, models, routes, services) makes the application modular, easier to understand, maintain, and scale.

1.5 Configure Environment Variables (.env):

Create a file named .env in the project root. Add your Plivo credentials, MongoDB connection string, and Plivo phone number.

ini
# .env

# Plivo Credentials
# Get these from your Plivo Console: https://console.plivo.com/dashboard/
PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
PLIVO_PHONE_NUMBER=+1XXXXXXXXXX # Your Plivo SMS-enabled number in E.164 format

# MongoDB Connection
MONGO_URI=mongodb://localhost:27017/appointment_scheduler # Replace with your MongoDB connection string

# Application Settings
PORT=3000
REMINDER_MINUTES_BEFORE=60 # Send reminder 60 minutes before appointment
CRON_SCHEDULE=* * * * * # Run scheduler check every minute
  • Obtaining Plivo Credentials: Log in to your Plivo account. Your Auth ID and Auth Token are displayed prominently on the main dashboard overview page.
  • Obtaining Plivo Number: Navigate to Phone Numbers > Your Numbers in the Plivo console. Ensure the number you use is SMS enabled. Use the E.164 format (e.g., +14155551212).
  • MongoDB URI: If using a local MongoDB instance, the default URI is usually mongodb://localhost:27017/your_db_name. For cloud providers like MongoDB Atlas, copy the connection string provided by the service.
  • Security: The .env file contains sensitive credentials. Never commit this file to version control.

1.6 Configure .gitignore:

Create a .gitignore file in the project root to prevent committing sensitive files and unnecessary directories.

plaintext
# .gitignore

# Dependencies
node_modules/

# Environment variables
.env

# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# OS generated files
.DS_Store
Thumbs.db

1.7 Set up Basic Express Server (server.js):

Create the main entry point for your application.

javascript
// server.js
require('dotenv').config(); // Load environment variables from .env file
const express = require('express');
const connectDB = require('./config/db');
const appointmentRoutes = require('./routes/appointments');
const { startScheduler } = require('./services/scheduler');
const mongoose = require('mongoose'); // Needed for health check later

const app = express();

// Connect to Database
connectDB();

// Init Middleware
app.use(express.json()); // Enable parsing JSON request bodies

// Define Routes
app.get('/', (req, res) => res.send('Appointment Scheduler API Running'));
app.use('/api/appointments', appointmentRoutes); // Mount appointment routes

// Health Check Endpoint (Example)
app.get('/healthz', async (req, res) => {
  try {
    // Check database connection status (Mongoose connection state)
    const dbState = mongoose.connection.readyState;
    // 0: disconnected; 1: connected; 2: connecting; 3: disconnecting
    if (dbState === 1) {
       res.status(200).send('OK');
    } else {
       throw new Error(`DB not connected. State: ${dbState}`);
    }
  } catch (error) {
    console.error("Health check failed:", error.message);
    res.status(503).send('Service Unavailable');
  }
});


const PORT = process.env.PORT || 3000;

// Basic Error Handling (Example - Enhance as needed)
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send('Something broke!');
});

// Start server only if not in test environment (to allow Supertest to bind port)
// And export app for testing purposes
if (process.env.NODE_ENV !== 'test') {
    app.listen(PORT, () => {
      console.log(`Server started on port ${PORT}`);
      // Start the reminder scheduler after the server starts
      startScheduler();
    });
}

module.exports = app; // Export the app instance for testing
  • require('dotenv').config(): This line must be at the top to ensure environment variables are loaded before other modules need them.
  • connectDB(): Function to establish the MongoDB connection (we'll create this next).
  • express.json(): Middleware to parse incoming JSON payloads.
  • /api/appointments: Base path for all appointment-related API endpoints.
  • startScheduler(): Function to initialize the node-cron job (created later).
  • module.exports = app;: Exports the Express app instance, which is necessary for integration testing frameworks like Supertest. Note the conditional app.listen to prevent the server from starting automatically during tests.

1.8 Set up Database Connection (config/db.js):

Create the logic to connect to your MongoDB instance using Mongoose.

javascript
// config/db.js
const mongoose = require('mongoose');

const connectDB = async () => {
  try {
    // Mongoose 6+ requires MONGO_URI to be defined
    if (!process.env.MONGO_URI) {
        throw new Error('MONGO_URI is not defined in environment variables.');
    }
    await mongoose.connect(process.env.MONGO_URI, {
      // Mongoose 6+ uses these defaults, but explicit for clarity
      useNewUrlParser: true,
      useUnifiedTopology: true,
    });
    console.log('MongoDB Connected...');
  } catch (err) {
    console.error('MongoDB Connection Error:', err.message);
    // Exit process with failure
    process.exit(1);
  }
};

module.exports = connectDB;
  • mongoose.connect(): Attempts to connect to the MongoDB instance specified by the MONGO_URI environment variable. Includes a check that the URI is defined.
  • Error Handling: Logs connection errors and exits the application if the database connection fails, as the application likely cannot function without it.

Now you have a basic project structure, dependencies installed, and a simple Express server ready. You can test this basic setup by running:

bash
npm run dev # Uses nodemon if installed
# or
npm start   # Uses node directly

You should see "Server started on port 3000" and "MongoDB Connected..." in your console. Accessing http://localhost:3000/ in your browser should show "Appointment Scheduler API Running". Accessing http://localhost:3000/healthz should show "OK".


2. Implementing core functionality: Appointments

Now let's define the data structure for our appointments and create the core logic for managing them.

2.1 Define the Appointment Schema (models/Appointment.js):

Use Mongoose to define the structure of appointment documents in MongoDB.

javascript
// models/Appointment.js
const mongoose = require('mongoose');

const AppointmentSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Patient name is required.'],
    trim: true,
  },
  phoneNumber: {
    type: String,
    required: [true, 'Phone number is required.'],
    // Example: Very basic check for '+' prefix and digits. Doesn't enforce length or full E.164 rules.
    // Use a library like `google-libphonenumber` for production validation.
    validate: {
      validator: function(v) {
        return /^\+\d+$/.test(v);
      },
      message: props => `${props.value} is not a valid phone number format (must start with + and contain digits). Use E.164 format.`
    },
  },
  appointmentTime: {
    type: Date,
    required: [true, 'Appointment time is required.'],
  },
  timeZone: {
    type: String,
    required: [true, 'Time zone is required (e.g., America/New_York).'], // Store the original time zone
  },
  reminderSent: {
    type: Boolean,
    default: false, // Track if a reminder has been sent
  },
}, { timestamps: true }); // Enable timestamps

// Index appointmentTime for faster querying by the scheduler
AppointmentSchema.index({ appointmentTime: 1, reminderSent: 1 });

module.exports = mongoose.model('Appointment', AppointmentSchema);
  • required: Fields marked as required must be provided.
  • trim: Removes leading/trailing whitespace from strings.
  • phoneNumber Validation: Includes a very basic regex validator. It checks for a leading + followed by digits but doesn't enforce length rules or other E.164 constraints. For production, using a dedicated library like google-libphonenumber is strongly recommended for robust validation.
  • appointmentTime (Date): Stores the appointment date and time. Crucially, Mongoose stores dates in UTC format by default. This is good practice.
  • timeZone (String): Stores the original time zone provided by the user (e.g., 'America/New_York', 'Europe/London'). This is important for accurately calculating reminder times and formatting messages if needed. You can use libraries like moment-timezone to validate time zone strings.
  • reminderSent (Boolean): A flag to prevent sending multiple reminders for the same appointment.
  • timestamps: true: Automatically adds createdAt and updatedAt fields.
  • index: Creates a database index on appointmentTime and reminderSent. This significantly speeds up the scheduler's queries when searching for appointments needing reminders.

2.2 Implement Appointment Controller (controllers/appointmentController.js):

Create functions to handle the logic for creating and potentially retrieving appointments.

javascript
// controllers/appointmentController.js
const Appointment = require('../models/Appointment');

// @desc    Create a new appointment
// @route   POST /api/appointments
// @access  Public (Add auth later if needed)
exports.createAppointment = async (req, res, next) => {
  try {
    const { name, phoneNumber, appointmentTime, timeZone } = req.body;

    // Input validation should ideally be handled by middleware (e.g., express-validator).
    // Basic checks can remain as a fallback if needed.
    // if (!name || !phoneNumber || !appointmentTime || !timeZone) {
    //   return res.status(400).json({ success: false, message: 'Missing required fields.' });
    // }

    // Create appointment (Mongoose handles Date conversion if string is ISO 8601 format)
    const newAppointment = await Appointment.create({
      name,
      phoneNumber,
      appointmentTime, // Expecting ISO 8601 format string e.g., ""2025-12-31T14:30:00.000Z"" or a JS Date object
      timeZone,
      reminderSent: false // Explicitly set, though it's the default
    });

    console.log(`Appointment created: ${newAppointment._id} for ${newAppointment.name} at ${newAppointment.appointmentTime}`);

    res.status(201).json({
      success: true,
      data: newAppointment,
      message: 'Appointment scheduled successfully.'
    });

  } catch (error) {
    console.error('Error creating appointment:', error);
    // Handle Mongoose validation errors specifically
    if (error.name === 'ValidationError') {
        const messages = Object.values(error.errors).map(val => val.message);
        return res.status(400).json({ success: false, message: messages.join(', ') });
    }
    // Generic error handling
    res.status(500).json({ success: false, message: 'Server error creating appointment.' });
    // Optional: pass error to a global error handler `next(error)`
  }
};

// @desc    Get all appointments (Example - add filtering/pagination for production)
// @route   GET /api/appointments
// @access  Public (Add auth later if needed)
exports.getAppointments = async (req, res, next) => {
    try {
        // Example: Add basic pagination
        const page = parseInt(req.query.page, 10) || 1;
        const limit = parseInt(req.query.limit, 10) || 10;
        const skip = (page - 1) * limit;

        const appointments = await Appointment.find()
            .sort({ appointmentTime: 1 }) // Sort by date
            .skip(skip)
            .limit(limit);

        const totalAppointments = await Appointment.countDocuments(); // Get total count for pagination info

        res.status(200).json({
            success: true,
            count: appointments.length,
            pagination: {
                currentPage: page,
                totalPages: Math.ceil(totalAppointments / limit),
                totalItems: totalAppointments
            },
            data: appointments
        });
    } catch (error) {
        console.error('Error fetching appointments:', error);
        res.status(500).json({ success: false, message: 'Server error fetching appointments.' });
    }
};

// Add functions for getting single appointment, updating, deleting as needed
  • createAppointment: Extracts data from the request body, relies on middleware for primary validation (recommended), creates a new Appointment document using the Mongoose model, and returns the created appointment. Includes fallback handling for potential Mongoose validation errors.
  • getAppointments: Retrieves appointments, now including basic pagination (page, limit query parameters) and sorting.
  • Time Handling: Assumes the appointmentTime in the request body is either a JavaScript Date object or an ISO 8601 formatted string (like 2025-05-15T10:00:00.000Z). Mongoose will automatically convert this to a UTC Date object for storage.

2.3 Database Further Considerations:

  • Migrations: For evolving schemas in production, use a migration library like migrate-mongoose or handle schema changes manually with scripts.
  • Data Population: For testing, you could create scripts to populate the database with sample appointments using the Mongoose model.

3. Building the API Layer

Define the actual HTTP routes that map to the controller functions.

3.1 Define Appointment Routes (routes/appointments.js):

Use Express Router to define the endpoints. We'll add validation middleware later in the Security section.

javascript
// routes/appointments.js
const express = require('express');
const { createAppointment, getAppointments } = require('../controllers/appointmentController');
// We will add validation middleware here later (e.g., using express-validator)
// const { validateAppointment } = require('../middleware/validator');

const router = express.Router();

// Route for creating an appointment
// Apply validation middleware before the controller (example shown, implement later)
// router.post('/', validateAppointment, createAppointment);
router.post('/', createAppointment); // Currently without validation middleware

// Route for getting all appointments
router.get('/', getAppointments);

// Add routes for GET /:id, PUT /:id, DELETE /:id as needed

module.exports = router;
  • This file links the POST /api/appointments request path to the createAppointment controller function and GET /api/appointments to getAppointments.
  • We've noted where validation middleware should ideally be added.

Testing the Endpoint:

You can now test creating an appointment using curl or a tool like Postman.

Using curl:

bash
curl -X POST http://localhost:3000/api/appointments \
-H "Content-Type: application/json" \
-d '{
    "name": "Alice Smith",
    "phoneNumber": "+15551234567",
    "appointmentTime": "2025-12-25T15:00:00.000Z",
    "timeZone": "America/New_York"
}'

Expected Response (JSON):

json
{
    "success": true,
    "data": {
        "name": "Alice Smith",
        "phoneNumber": "+15551234567",
        "appointmentTime": "2025-12-25T15:00:00.000Z",
        "timeZone": "America/New_York",
        "reminderSent": false,
        "_id": "someGeneratedMongoId",
        "createdAt": "...",
        "updatedAt": "..."
    },
    "message": "Appointment scheduled successfully."
}

Check your MongoDB database to confirm the appointment was saved correctly. Also check your server console logs. Try the GET endpoint too: curl http://localhost:3000/api/appointments?limit=5


4. Integrating with Plivo for SMS Reminders

Now, let's integrate the Plivo API to enable sending SMS reminders.

4.1 Create Plivo Service (services/plivoService.js):

This service encapsulates all interactions with the Plivo API.

javascript
// services/plivoService.js
const plivo = require('plivo');
require('dotenv').config(); // Ensure env vars are loaded

// Input Validation (Basic)
if (!process.env.PLIVO_AUTH_ID || !process.env.PLIVO_AUTH_TOKEN || !process.env.PLIVO_PHONE_NUMBER) {
    console.error(""Plivo credentials or phone number missing in .env file."");
    // In a real app, you might want to prevent the app from starting
    // or disable scheduling if Plivo isn't configured.
    // For now, we log the error. The client initialization will likely fail later.
}

// Initialize Plivo client only if credentials exist
let client;
if (process.env.PLIVO_AUTH_ID && process.env.PLIVO_AUTH_TOKEN) {
    client = new plivo.Client(process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN);
} else {
    console.warn(""Plivo client not initialized due to missing credentials."");
}

const plivoPhoneNumber = process.env.PLIVO_PHONE_NUMBER;

/**
 * Sends an SMS message using the Plivo API.
 * @param {string} to - The recipient's phone number in E.164 format.
 * @param {string} text - The message content.
 * @returns {Promise<object>} - The Plivo API response.
 * @throws {Error} - If sending fails or Plivo client isn't initialized.
 */
const sendSms = async (to, text) => {
  if (!client) {
    throw new Error(""Plivo client is not initialized. Cannot send SMS."");
  }
  if (!plivoPhoneNumber) {
    throw new Error(""Plivo source phone number (PLIVO_PHONE_NUMBER) is not configured."");
  }
  if (!to || !text) {
    throw new Error(""Recipient phone number (to) and message text are required."");
  }

  console.log(`Attempting to send SMS via Plivo from ${plivoPhoneNumber} to ${to}`);

  try {
    const response = await client.messages.create(
      plivoPhoneNumber, // src
      to,              // dst
      text             // text
      // Optional parameters (e.g., { url: 'status_callback_url' }) can be added here
    );
    // Ensure response structure is handled safely
    const messageUuid = response && response.messageUuid ? response.messageUuid[0] : 'N/A';
    console.log(`Plivo SMS Response: Message UUID ${messageUuid}`);
    // Plivo response includes message_uuid which is useful for tracking
    return response;
  } catch (error) {
    console.error(""Plivo API Error:"", error);
    // Rethrow or handle specific Plivo errors (e.g., invalid number format, insufficient credits)
    throw new Error(`Failed to send SMS via Plivo: ${error.message}`);
  }
};

module.exports = {
  sendSms,
  // Export client if needed elsewhere, but usually better to keep interactions within the service
};
  • Initialization: Reads Plivo credentials and the source phone number from environment variables. It includes checks to ensure these are present and warns if the client cannot be initialized.
  • sendSms Function: Takes the recipient number (to) and message text as arguments. It performs basic checks, then uses client.messages.create() to send the SMS.
  • Error Handling: Includes a try...catch block to handle errors during the API call (e.g., network issues, invalid credentials, invalid recipient number, insufficient funds). It logs the error and throws a new error for the calling function (the scheduler) to handle. Logs the Message UUID from the response for tracking.
  • Security: Relies on dotenv to load credentials, keeping them out of the codebase.

4.2 How to Obtain Plivo Credentials:

  1. Log in to your Plivo Console.
  2. Your Auth ID and Auth Token are displayed on the main dashboard overview page. Click the ""eye"" icon to reveal the Auth Token if it's hidden.
  3. Copy these values and paste them into your .env file for PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN.
  4. Navigate to Phone Numbers > Your Numbers.
  5. Copy the SMS-enabled number you want to use as the sender, ensuring it's in E.164 format (e.g., +14155551212), and paste it into your .env file for PLIVO_PHONE_NUMBER.

5. Implementing the Reminder Scheduler

Let's use node-cron to periodically check for appointments needing reminders and trigger the Plivo SMS service.

5.1 Create Scheduler Logic (services/scheduler.js):

This section uses the moment-timezone library for reliable time zone handling. Install it if you haven't already:

bash
npm install moment-timezone

Now, create the scheduler file:

javascript
// services/scheduler.js
const cron = require('node-cron');
const Appointment = require('../models/Appointment');
const { sendSms } = require('./plivoService'); // Use the Plivo service
const moment = require('moment-timezone'); // Use moment-timezone for reliable time zone handling

require('dotenv').config();

const CRON_SCHEDULE = process.env.CRON_SCHEDULE || '* * * * *'; // Default: every minute
const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE || '60', 10);

let task = null; // To hold the cron task instance
let isJobRunning = false; // Simple lock to prevent overlap

/**
 * Checks for appointments needing reminders and sends SMS via Plivo.
 */
const checkAndSendReminders = async () => {
  if (isJobRunning) {
    console.log(`[${new Date().toISOString()}] Reminder check skipped: Previous job still running.`);
    return;
  }
  isJobRunning = true;
  console.log(`[${new Date().toISOString()}] Running reminder check...`);

  // Calculate the time window for reminders
  const now = moment.utc(); // Work with UTC time
  const reminderWindowStart = now.clone(); // Avoid modifying 'now' directly
  const reminderWindowEnd = now.clone().add(REMINDER_MINUTES_BEFORE, 'minutes');

  console.log(`Checking for appointments between ${reminderWindowStart.toISOString()} and ${reminderWindowEnd.toISOString()}`);

  try {
    // Find appointments within the window that haven't had a reminder sent
    const appointmentsToRemind = await Appointment.find({
      appointmentTime: {
        $gte: reminderWindowStart.toDate(), // Greater than or equal to now
        $lte: reminderWindowEnd.toDate()    // Less than or equal to X minutes from now
      },
      reminderSent: false // Only find those not yet reminded
    }).lean(); // Use .lean() for performance if not modifying Mongoose docs directly

    console.log(`Found ${appointmentsToRemind.length} appointments needing reminders.`);

    if (appointmentsToRemind.length === 0) {
      isJobRunning = false; // Release lock
      return; // Nothing to do
    }

    // Process reminders sequentially to avoid overwhelming Plivo API or database
    for (const appointment of appointmentsToRemind) {
      console.log(`Processing reminder for appointment ID: ${appointment._id}`);

      // Format the appointment time for the SMS message *using the stored time zone*
      let localAppointmentTime = 'N/A';
      try {
          localAppointmentTime = moment(appointment.appointmentTime) // This is UTC
                                       .tz(appointment.timeZone) // Convert to user's timezone
                                       .format('h:mm A z'); // e.g., "3:00 PM EDT"
      } catch (tzError) {
          console.error(`Error formatting time for appointment ${appointment._id} with timezone ${appointment.timeZone}:`, tzError);
          // Use UTC time as fallback or skip? For now, log and continue.
          localAppointmentTime = moment(appointment.appointmentTime).utc().format('h:mm A [UTC]');
      }


      const messageText = `Hi ${appointment.name}, this is a reminder for your appointment scheduled at ${localAppointmentTime}.`;

      try {
        // Send SMS using the Plivo service
        await sendSms(appointment.phoneNumber, messageText);
        console.log(`Reminder SMS sent successfully to ${appointment.phoneNumber} for appointment ${appointment._id}`);

        // IMPORTANT: Mark the appointment as reminderSent in the database
        await Appointment.updateOne({ _id: appointment._id }, { $set: { reminderSent: true } });
        console.log(`Marked appointment ${appointment._id} as reminderSent.`);

      } catch (smsError) {
        console.error(`Failed to send reminder or update status for appointment ${appointment._id}:`, smsError);
        // Decide on retry logic:
        // - Log the error and move on (simplest).
        // - Implement a retry count on the appointment document.
        // - Push failed jobs to a separate queue for later processing.
        // For this guide, we'll log and continue.
      }
      // Optional: Add a small delay between sends if hitting rate limits
      // await new Promise(resolve => setTimeout(resolve, 200)); // 200ms delay
    }
  } catch (dbError) {
    console.error('Error querying appointments for reminders:', dbError);
  } finally {
      isJobRunning = false; // Release lock regardless of outcome
      console.log(`[${new Date().toISOString()}] Reminder check finished.`);
  }
};

/**
 * Starts the cron job.
 */
const startScheduler = () => {
  // Validate cron schedule format (basic check)
  if (!cron.validate(CRON_SCHEDULE)) {
      console.error(`Invalid CRON_SCHEDULE format: "${CRON_SCHEDULE}". Scheduler not started.`);
      return;
  }

  console.log(`Scheduler starting with schedule: ${CRON_SCHEDULE}`);

  // Schedule the task
  task = cron.schedule(CRON_SCHEDULE, checkAndSendReminders, {
    scheduled: true,
    // Optional: specify timezone for the cron schedule itself if needed,
    // but the logic inside checkAndSendReminders uses UTC/moment-timezone.
    // timezone: "America/New_York"
  });

  // Optional: You can add listeners for job start/stop if needed
  // task.start(); // Already starts if scheduled: true
  console.log("Reminder scheduler job registered.");
};

/**
 * Stops the cron job gracefully.
 */
const stopScheduler = () => {
  if (task) {
    console.log("Stopping reminder scheduler...");
    task.stop();
    task = null;
    console.log("Scheduler stopped.");
  }
};

// Export functions
module.exports = {
  startScheduler,
  stopScheduler,
  checkAndSendReminders // Export for potential manual triggering or testing
};
  • Dependencies: Requires node-cron, the Appointment model, the plivoService, and moment-timezone.
  • Configuration: Reads CRON_SCHEDULE and REMINDER_MINUTES_BEFORE from .env.
  • checkAndSendReminders:
    • Uses a simple isJobRunning flag to prevent overlapping executions if a check takes longer than the interval.
    • Calculates the time window (now to REMINDER_MINUTES_BEFORE from now) in UTC using moment.utc().
    • Queries MongoDB for appointments within this window where reminderSent is false. Uses .lean() for better performance as we only need to read data.
    • Iterates through found appointments.
    • Time Zone Handling: Uses moment(appointment.appointmentTime).tz(appointment.timeZone).format(...) to format the appointment time correctly in the user's specified time zone for the SMS message. Includes basic error handling for invalid time zones.
    • Calls sendSms from the plivoService.
    • Crucially: Updates the appointment document in the database, setting reminderSent to true after successfully sending the SMS to prevent duplicates.
    • Includes error handling for both SMS sending and database updates.
  • startScheduler:
    • Validates the CRON_SCHEDULE format using cron.validate().
    • Uses cron.schedule() to set up the recurring job, calling checkAndSendReminders based on the schedule.
    • Stores the task instance to allow stopping it later.
  • stopScheduler: Provides a function to gracefully stop the scheduled task if needed (e.g., during application shutdown).
  • Integration: The startScheduler function is called in server.js after the server successfully starts.

Frequently Asked Questions

When should I use moment-timezone in Node.js?

Use moment-timezone when accurate time zone handling and conversion are needed. This is essential for the appointment reminder project to ensure the correct time is displayed in the SMS message regardless of the user's location.

How to structure a Node.js Express project?

Organize code into modular components like config, controllers, models, routes, and services, as seen in the project structure example. This promotes maintainability and scalability.

Can I use a different SMS provider?

While this guide uses Plivo, you could adapt it to use other SMS providers by using their respective Node.js SDKs and updating the Plivo-specific code in the 'services/plivoService.js' file.

How to set up Plivo for SMS reminders?

First, sign up for a Plivo account and obtain your Auth ID and Auth Token from the Plivo console dashboard. Purchase an SMS-enabled Plivo phone number and ensure it's configured to send messages to your target region. Save these credentials and your Plivo number securely in your project's .env file.

How to schedule SMS reminders with Node.js?

Use the node-cron library to schedule a recurring task. This task should query your database for upcoming appointments, then use the Plivo Node.js SDK to send SMS messages to the appropriate phone numbers.

What is Plivo used for in this project?

Plivo is a cloud communications platform that provides the SMS API used to send appointment reminders. The application interacts with the Plivo API through the Plivo Node.js SDK.

What database is used for appointment scheduling?

MongoDB is used as the database, with Mongoose serving as the Object Data Modeling (ODM) library. Mongoose simplifies interaction with MongoDB and allows schema definition for appointments.

How to handle time zones for appointment reminders?

Store the user's time zone information in the database along with the UTC appointment time. Use moment-timezone or a similar library to convert the UTC appointment time into the user's time zone when formatting the reminder message.

What Node.js frameworks are used in the appointment scheduler?

The project uses Express.js as the web application framework and Node.js as the runtime environment. Express.js provides a minimal and flexible foundation for building the API server.

How to prevent duplicate SMS reminder messages?

Include a 'reminderSent' boolean field in your appointment data model. After successfully sending a reminder, set this field to 'true' in the database. This will prevent future checks from sending additional reminders.

Why does the app use dotenv?

The dotenv module is used to load environment variables from a .env file. This allows you to store sensitive information, like API keys and database credentials, separately from your code.

What is node-cron and what's its purpose?

Node-cron is a task scheduler for Node.js that allows you to run tasks on a predefined schedule, similar to cron jobs in Unix-like systems. It's used here to periodically trigger the reminder check and send SMS reminders before appointments.

How to install required project dependencies?

Run 'npm install express mongoose plivo node-cron dotenv moment-timezone' in your project directory to install all the required dependencies. Optionally, install 'nodemon' as a development dependency for automatic server restarts.

How to run the Node.js scheduler application?

After setting up the project and installing dependencies, run 'npm run dev' (with nodemon) or 'npm start' to start the application. The reminder scheduler will start automatically after the server starts.