This guide provides a step-by-step walkthrough for building a production-ready appointment reminder system using Node.js, Express, and the Infobip SMS API. We'll cover everything from project setup and core scheduling logic to robust error handling, security considerations, and deployment.
By the end of this tutorial, you will have a functional application capable of accepting reminder requests via an API, scheduling them, and reliably sending SMS notifications at the specified time using Infobip. This solves the common business need for automated, time-sensitive communication like appointment confirmations, payment due dates, or event reminders.
Last Updated: October 26, 2023
Project Overview and Goals
Goal: To create a backend service that can:
Receive requests to schedule an SMS reminder via a REST API endpoint.
Store reminder details (phone number, message, scheduled time).
Reliably check for due reminders at regular intervals.
Send the scheduled SMS messages using the Infobip API when they become due.
Provide basic mechanisms for logging, error handling, and security.
Problem Solved: Automating time-sensitive SMS notifications, reducing manual effort, and improving user engagement or adherence (e.g., reducing missed appointments).
Technologies Used:
Node.js: Asynchronous JavaScript runtime for building the backend server.
Express: Minimalist web framework for Node.js, used to create the REST API.
Infobip Node.js SDK: Simplifies interaction with the Infobip SMS API.
node-cron: A simple cron-like job scheduler for Node.js, used to trigger checks for due reminders.
dotenv: Loads environment variables from a .env file.
uuid: Generates unique IDs for reminders.
express-validator: Middleware for request validation.
(Optional but Recommended) SQLite: A simple file-based SQL database for persisting reminders (can be swapped for other databases).
System Architecture:
Placeholder: Insert a system architecture diagram image (e.g., PNG or SVG) here. The diagram should illustrate the flow from Client App -> Node.js/Express API -> Reminder Service -> Database, with the Reminder Service interacting with node-cron and the Infobip Service, which in turn calls the Infobip API.
Basic understanding of JavaScript, Node.js, REST APIs, and asynchronous programming.
A text editor or IDE (e.g., VS Code).
A tool for testing APIs (e.g., Postman or curl).
Final Outcome: A running Node.js application with an API endpoint to schedule SMS reminders, which are then automatically sent at the correct time via Infobip.
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
Create Project Directory:
Open your terminal and create a new directory for the project, then navigate into it.
Bash
mkdir node-infobip-scheduler
cd node-infobip-scheduler
Initialize npm:
Initialize the project using npm. The -y flag accepts default settings.
Bash
npm init -y
This creates a package.json file.
Enable ES Modules:
Since the code examples use import/export syntax (ES Modules), add the following line to your package.json file:
JSON
// package.json{// ... other settings"type":"module",// ... rest of the file}
Install Dependencies:
Install Express, the Infobip SDK, node-cron, dotenv, uuid, and express-validator.
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
db/*.sqlite # Or your specific DB file extension
Create .env File:
Create a .env file in the root directory. This file will hold your Infobip credentials and other configuration. Remember to add .env to your .gitignore file.
ini
# .envPORT=3000INFOBIP_API_KEY=YOUR_INFOBIP_API_KEY
INFOBIP_BASE_URL=YOUR_INFOBIP_BASE_URL # Find this in your Infobip dashboard/API docs# Optional: Database path if using SQLiteDATABASE_PATH=./db/reminders.sqlite
PORT: The port your Express server will listen on.
INFOBIP_API_KEY: Your secret API key from Infobip.
INFOBIP_BASE_URL: Your unique base URL provided by Infobip (check your dashboard).
Your Base URL is usually visible on the homepage dashboard after login or in the API documentation section. It's specific to your account/region.
Navigate to Apps > API Keys (or similar section).
Click the button to create a new API key (e.g., "New API Key", "Create API Key", or similar text) or use an existing one.
Copy the generated API Key and your Base URL into your .env file.
Add npm Scripts:
Update the scripts section in your package.json for easier starting and development:
JSON
// package.json{// ... other settings"type":"module",// Ensure this is present"scripts":{"start":"node src/app.js","dev":"nodemon src/app.js","test":"echo \"Error: no test specified\" && exit 1"// Placeholder for tests},// ... rest of the file}
npm start: Runs the application using Node.
npm run dev: Runs the application using nodemon for auto-restarts.
2. Implementing Core Functionality
Now, let's implement the core logic: configuring Infobip, managing reminders, and setting up the scheduler.
Configure Infobip SDK (src/config/infobip.js):
Create a file to initialize the Infobip client instance.
JavaScript
// src/config/infobip.jsimport { Infobip, AuthType } from'@infobip-api/sdk';
import dotenv from'dotenv';
dotenv.config(); // Load environment variablesif (!process.env.INFOBIP_BASE_URL || !process.env.INFOBIP_API_KEY) {
console.error(""FATALERROR: InfobipAPIKey or BaseURL not configured in .env file."");
process.exit(1); // Exit if essential config is missing
}
const infobipClient = newInfobip({
baseUrl: process.env.INFOBIP_BASE_URL,
apiKey: process.env.INFOBIP_API_KEY,
authType: AuthType.ApiKey,
});
exportdefault infobipClient;
We import the necessary parts from the SDK and dotenv.
We explicitly check for the presence of API Key and Base URL, exiting if they are missing to prevent runtime errors.
We create and export a configured infobipClient instance.
Setup Database (Optional - SQLite Example) (src/db/database.js and src/db/schema.sql):
If using SQLite, set up the database connection and ensure the table exists.
Schema Definition (src/db/schema.sql):
SQL
-- src/db/schema.sqlCREATE TABLE IF NOTEXISTS reminders (
id TEXT PRIMARY KEY,
phoneNumber TEXT NOT NULL,
message TEXT NOT NULL,
scheduleTime TEXT NOT NULL, -- Store as ISO 8601 string (UTC)
status TEXT NOT NULLDEFAULT'pending', -- 'pending', 'sent', 'failed'
createdAt TEXT DEFAULTCURRENT_TIMESTAMP,
updatedAt TEXT DEFAULTCURRENT_TIMESTAMP
);
-- Optional: Trigger to update 'updatedAt' timestamp automaticallyCREATETRIGGER IF NOTEXISTS update_reminders_updatedAt
AFTER UPDATEON reminders FOREACHROWBEGINUPDATE reminders SET updatedAt =CURRENT_TIMESTAMPWHERE id = OLD.id;
END;
-- Optional: Index for faster querying of pending reminders by timeCREATE INDEX IF NOTEXISTS idx_reminders_pending_scheduleTime
ON reminders (status, scheduleTime)
WHERE status ='pending';
Database Setup (src/db/database.js):(Note: This code uses the ES Module pattern to get the directory path, replacing the CommonJS __dirname)
JavaScript
// src/db/database.jsimport sqlite3 from'sqlite3';
import { open } from'sqlite';
import fs from'fs';
import path, { dirname } from'path';
import { fileURLToPath } from'url';
import dotenv from'dotenv';
// ES Module equivalent for __dirnameconst __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
dotenv.config();
// Use path.resolve to ensure the path is absolute or relative to the project root as intendedconst defaultDbPath = path.resolve(__dirname, 'reminders.sqlite');
const dbPath = process.env.DATABASE_PATH || defaultDbPath;
const dbDir = path.dirname(dbPath);
// Ensure the database directory existsif (!fs.existsSync(dbDir)) {
try {
fs.mkdirSync(dbDir, { recursive: true });
console.log(`Database directory created at: ${dbDir}`);
} catch (error) {
console.error(`Error creating database directory at ${dbDir}:`, error);
process.exit(1);
}
}
let db;
asyncfunctioninitializeDatabase() {
if (db) return db; // Return existing connection if already initializedtry {
db = awaitopen({
filename: dbPath,
driver: sqlite3.Database
});
console.log('Connected to the SQLite database.');
// Read and execute schema SQLconst schemaPath = path.resolve(__dirname, 'schema.sql');
const schemaSql = fs.readFileSync(schemaPath, 'utf8');
await db.exec(schemaSql);
console.log('Database schema ensured.');
return db;
} catch (error) {
console.error('Error initializing SQLite database:', error);
process.exit(1); // Exit if database cannot be initialized
}
}
// Function to get the database instanceasyncfunctiongetDb() {
if (!db) {
awaitinitializeDatabase();
}
return db;
}
export { getDb, initializeDatabase }; // Export initializeDatabase if needed elsewhere
Uses the import.meta.url pattern to correctly determine the directory path in ES Modules.
Sets up a connection using the sqlite library (which provides async/await support over sqlite3).
Reads the schema.sql file and executes it to ensure the reminders table and indexes exist on startup.
Handles potential errors during initialization.
Reminder Service (src/services/reminderService.js):
This service handles the business logic for creating, retrieving, and updating reminders.
JavaScript
// src/services/reminderService.jsimport { v4 as uuidv4 } from'uuid';
// Choose ONE implementation: Database or In-Memory// Option 1: Database (SQLite) - Recommended for persistenceimport { getDb } from'../db/database.js';
// Option 2: In-Memory (Simpler, data lost on restart) - Comment out DB import if using this// let reminders = []; // In-memory storeimport infobipClient from'../config/infobip.js';
// --- Database Implementation (SQLite) ---// Make sure the 'getDb' import above is active if using this section.// Comment out this entire section if using the In-Memory alternative below.asyncfunctionaddReminder(phoneNumber, message, scheduleTime) {
const db = awaitgetDb();
const id = uuidv4();
const isoScheduleTime = newDate(scheduleTime).toISOString(); // Ensure UTC ISO format// Basic validation (more robust validation in controller/middleware)if (isNaN(newDate(isoScheduleTime).getTime())) {
thrownewError('Invalid scheduleTime format. Please use ISO 8601 format.');
}
if (newDate(isoScheduleTime) <= newDate()) {
thrownewError('Schedule time must be in the future.');
}
try {
const result = await db.run(
'INSERT INTO reminders (id, phoneNumber, message, scheduleTime) VALUES (?, ?, ?, ?)',
[id, phoneNumber, message, isoScheduleTime]
);
console.log(`Reminder added with ID: ${id}`);
return { id, phoneNumber, message, scheduleTime: isoScheduleTime, status: 'pending' };
} catch (error) {
console.error('Error adding reminder to DB:', error);
thrownewError('Failed to schedule reminder.'); // Propagate generic error
}
}
asyncfunctiongetDueReminders() {
const db = awaitgetDb();
const now = newDate().toISOString();
try {
// Select pending reminders where scheduleTime is now or in the pastconst reminders = await db.all(
""SELECT * FROM reminders WHERE status = 'pending'AND scheduleTime <= ?"",
[now]
);
return reminders;
} catch (error) {
console.error('Error fetching due reminders:', error);
return []; // Return empty array on error to prevent scheduler crash
}
}
asyncfunctionupdateReminderStatus(id, status, errorInfo = null) {
const db = awaitgetDb();
try {
// In a real app, you might store more detailed error infoconst updateSql = status === 'failed'
? ""UPDATE reminders SET status = ?, updatedAt = CURRENT_TIMESTAMPWHERE id = ?""// Add error logging/storage here if needed
: ""UPDATE reminders SET status = ?, updatedAt = CURRENT_TIMESTAMPWHERE id = ?"";
await db.run(updateSql, [status, id]);
console.log(`Reminder ${id} status updated to: ${status}`);
if (status === 'failed' && errorInfo) {
console.error(`Reminder ${id} failed:`, errorInfo);
}
} catch (error) {
console.error(`Error updating reminder ${id} status to ${status}:`, error);
// Decide if this error is critical enough to stop the scheduler or just log
}
}
// --- End Database Implementation ---/*
// --- In-Memory Implementation (Alternative - Simpler, but not persistent) ---
// Uncomment this section and comment out the Database Implementation above if using this.
// Ensure the 'getDb' import at the top is commented out.
let reminders = []; // In-memory store
async function addReminder(phoneNumber, message, scheduleTime) {
const id = uuidv4();
const isoScheduleTime = new Date(scheduleTime).toISOString();
if (isNaN(new Date(isoScheduleTime).getTime())) {
throw new Error('Invalid scheduleTime format. Please use ISO 8601 format.');
}
if (new Date(isoScheduleTime) <= new Date()) {
throw new Error('Schedule time must be in the future.');
}
const newReminder = { id, phoneNumber, message, scheduleTime: isoScheduleTime, status: 'pending', createdAt: new Date().toISOString() };
reminders.push(newReminder);
console.log(`Reminder added with ID: ${id}`);
return newReminder;
}
async function getDueReminders() {
const now = new Date();
return reminders.filter(r => r.status === 'pending' && new Date(r.scheduleTime) <= now);
}
async function updateReminderStatus(id, status, errorInfo = null) {
const index = reminders.findIndex(r => r.id === id);
if (index !== -1) {
reminders[index].status = status;
reminders[index].updatedAt = new Date().toISOString();
console.log(`Reminder ${id} status updated to: ${status}`);
if (status === 'failed' && errorInfo) {
console.error(`Reminder ${id} failed:`, errorInfo);
}
} else {
console.warn(`Attempted to update status for non-existent reminder ID: ${id}`);
}
}
// --- End In-Memory Implementation ---
*/// --- Infobip Sending Logic (Used by both implementations) ---asyncfunctionsendSms(phoneNumber, message) {
console.log(`Attempting to send SMS to ${phoneNumber}`);
try {
const response = await infobipClient.channels.sms.send({
messages: [
{
destinations: [{ to: phoneNumber }],
from: 'InfoSMS', // Or your registered Sender ID / purchased numbertext: message,
},
],
});
// Log success and potentially the message IDconst sentMessageInfo = response.data.messages[0];
console.log(`SMS sent successfully to ${phoneNumber}. Message ID: ${sentMessageInfo?.messageId}, Status: ${sentMessageInfo?.status?.name}`);
return { success: true, response: response.data };
} catch (error) {
console.error(`Error sending SMS via Infobip to ${phoneNumber}:`, error.response?.data || error.message);
// Extract meaningful error details if available from Infobip's responseconst errorDetails = error.response?.data?.requestError?.serviceException || { message: error.message }; // Ensure errorDetails is an object or stringreturn { success: false, error: errorDetails };
}
}
// Export the functions relevant to the chosen implementation (DB or In-Memory)export { addReminder, getDueReminders, updateReminderStatus, sendSms };
Provides functions to interact with the data store (either SQLite or in-memory). Includes clear comments guiding the user to choose ONE implementation.
Includes the sendSms function using the configured infobipClient.
Handles basic validation for schedule time.
Includes basic error logging for database and Infobip operations.
Crucially, it stores and compares dates in UTC (using toISOString()).
Scheduler Service (src/services/schedulerService.js):
This service uses node-cron to periodically check for and process due reminders.
JavaScript
// src/services/schedulerService.jsimport cron from'node-cron';
import { getDueReminders, updateReminderStatus, sendSms } from'./reminderService.js';
// Schedule a task to run every minute. Adjust the cron pattern as needed.// Pattern: second(opt) minute hour day(month) month day(week)// '* * * * *' = runs every minuteconst cronJob = cron.schedule('* * * * *', async () => {
console.log(`[${newDate().toISOString()}] Running scheduler: Checking for due reminders...`);
try {
const dueReminders = awaitgetDueReminders();
if (dueReminders.length === 0) {
// console.log(`[${new Date().toISOString()}] No due reminders found.`); // Optional: reduce noisereturn;
}
console.log(`[${newDate().toISOString()}] Found ${dueReminders.length} due reminders. Processing...`);
// Process reminders sequentially to avoid overwhelming downstream systems or rate limitsfor (const reminder of dueReminders) {
console.log(`Processing reminder ID: ${reminder.id} for ${reminder.phoneNumber}`);
const sendResult = awaitsendSms(reminder.phoneNumber, reminder.message);
if (sendResult.success) {
awaitupdateReminderStatus(reminder.id, 'sent');
} else {
// Basic retry could be added here, or just mark as failedconst errorMessage = sendResult.error?.message || JSON.stringify(sendResult.error);
console.error(`Failed to send reminder ID: ${reminder.id}. Error: ${errorMessage}`);
awaitupdateReminderStatus(reminder.id, 'failed', errorMessage);
// Consider implementing a more robust retry mechanism (e.g., exponential backoff)// or notifying an admin system about the failure.
}
// Optional: Add a small delay between sends if needed to respect rate limits// await new Promise(resolve => setTimeout(resolve, 100));
}
console.log(`[${newDate().toISOString()}] Finished processing batch of ${dueReminders.length} reminders.`);
} catch (error) {
// Catch errors during the fetching or processing loopconsole.error(`[${newDate().toISOString()}] CRITICAL ERROR in scheduler run:`, error);
// Potentially add alerting here if the scheduler itself fails critically
}
}, {
scheduled: false// Don't start automatically; we'll start it in app.js
});
functionstartScheduler() {
console.log('Starting scheduler service...');
cronJob.start();
}
functionstopScheduler() {
console.log('Stopping scheduler service...');
cronJob.stop();
}
export { startScheduler, stopScheduler };
Imports node-cron and the necessary functions from reminderService.
Defines a cron job (cronJob) that runs every minute (* * * * *). You can adjust this frequency based on your needs (e.g., */5 * * * * for every 5 minutes).
The job fetches due reminders, iterates through them, attempts to send the SMS, and updates the status accordingly ('sent' or 'failed').
Includes basic logging for scheduler activity and errors.
Exports startScheduler and stopScheduler functions to control the job lifecycle.
3. Building the API Layer
We'll now create the Express API endpoint to accept requests for scheduling reminders.
Request Validation Middleware (src/middleware/validator.js):
Define validation rules using express-validator.
JavaScript
// src/middleware/validator.jsimport { body, validationResult } from'express-validator';
const validateReminderRequest = [
// Validate phone number: basic check for E.164 format.// NOTE: This regex is a simplified check. For robust validation,// consider using a library like 'libphonenumber-js'.body('phoneNumber')
.trim()
.notEmpty().withMessage('Phone number is required.')
.matches(/^\+?[1-9]\d{1,14}$/).withMessage('Invalid phone number format. Use E.164 format (e.g., +14155552671).'),
// Validate message: ensure it's not empty and within reasonable lengthbody('message')
.trim()
.notEmpty().withMessage('Message is required.')
.isLength({ min: 1, max: 1600 }).withMessage('Message length must be between 1 and 1600 characters.'), // Max SMS length can vary// Validate scheduleTime: ensure it's a valid ISO 8601 date stringbody('scheduleTime')
.notEmpty().withMessage('Schedule time is required.')
.isISO8601({ strict: true, require_tld: false }).withMessage('Schedule time must be a valid ISO 8601 date string (e.g., 2024-12-31T10:00:00Z or 2024-12-31T10:00:00+01:00).')
.custom((value) => {
// Custom validation to ensure the date is in the futureif (newDate(value) <= newDate()) {
thrownewError('Schedule time must be in the future.');
}
returntrue;
}),
// Middleware function to handle validation results(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
// Format errors for a cleaner responseconst formattedErrors = errors.array().map(err => ({
field: err.param, // Use 'param' instead of 'path' for body validationmessage: err.msg,
value: err.value,
}));
return res.status(400).json({ errors: formattedErrors });
}
next(); // Proceed if validation passes
}
];
export { validateReminderRequest };
Uses body() to specify rules for phoneNumber, message, and scheduleTime.
Includes a note about the simplified phone number regex.
Includes checks for emptiness, format (E.164 for phone, ISO 8601 for date), length, and ensures the date is in the future.
The final middleware function checks validationResult and returns a 400 error with details if validation fails.
Reminder Controller (src/controllers/reminderController.js):
Handle the logic for the API endpoint.
JavaScript
// src/controllers/reminderController.jsimport { addReminder } from'../services/reminderService.js';
constscheduleReminder = async (req, res, next) => { // Added next for error handling// Validation has already passed via middlewareconst { phoneNumber, message, scheduleTime } = req.body;
try {
const newReminder = awaitaddReminder(phoneNumber, message, scheduleTime);
// Respond with the created reminder details (excluding sensitive info if any)
res.status(201).json({
message: 'Reminder scheduled successfully.',
reminder: {
id: newReminder.id,
phoneNumber: newReminder.phoneNumber, // Consider masking if neededmessage: newReminder.message,
scheduleTime: newReminder.scheduleTime,
status: newReminder.status,
}
});
} catch (error) {
// Handle errors from the service layer (e.g., DB error, invalid date logic)console.error('Error in scheduleReminder controller:', error);
// Pass the error to the global error handler OR send a specific response// Option 1: Pass to global handlernext(error);
// Option 2: Send specific response (less consistent if you have a global handler)// res.status(500).json({ message: 'Failed to schedule reminder.', error: error.message });
}
};
// Add other controllers here if needed (e.g., getReminderStatus, cancelReminder)export { scheduleReminder };
Imports the addReminder function.
Extracts validated data from req.body.
Calls the service function to create the reminder.
Responds with a 201 status code and the created reminder's details on success, or passes errors to the global handler (or sends 500).
Reminder Routes (src/routes/reminderRoutes.js):
Define the API routes and associate them with controllers and middleware.
JavaScript
// src/routes/reminderRoutes.jsimport express from'express';
import { scheduleReminder } from'../controllers/reminderController.js';
import { validateReminderRequest } from'../middleware/validator.js';
// Optional: Add authentication middleware here// import { authenticate } from '../middleware/auth.js';const router = express.Router();
// POST /api/reminders - Schedule a new reminder// Optional: Add authentication middleware: router.post('/', authenticate, validateReminderRequest, scheduleReminder);
router.post('/', validateReminderRequest, scheduleReminder);
// Add other routes here (GET /:id, DELETE /:id, etc.) if neededexportdefault router;
Creates an Express router instance.
Defines a POST route at /.
Applies the validateReminderRequest middleware first, then the scheduleReminder controller.
Includes a commented-out placeholder for authentication middleware.
Express App Setup (src/app.js):
Configure the main Express application, load middleware, routes, and start the server and scheduler.
JavaScript
// src/app.jsimport express from'express';
import dotenv from'dotenv';
import reminderRoutes from'./routes/reminderRoutes.js';
import { startScheduler, stopScheduler } from'./services/schedulerService.js'; // Import stopSchedulerimport { getDb, initializeDatabase } from'./db/database.js'; // Import DB init and getDb// Load environment variables
dotenv.config();
const app = express();
constPORT = process.env.PORT || 3000;
// --- Middleware ---
app.use(express.json()); // Parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies// --- Basic Logging Middleware ---
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => { // Log when response is finishedconst duration = Date.now() - start;
console.log(`[${newDate().toISOString()}] ${req.method}${req.originalUrl}${res.statusCode}${duration}ms`);
});
next();
});
// --- Routes ---
app.get('/health', (req, res) => {
// Simple health check endpoint
res.status(200).json({ status: 'UP', timestamp: newDate().toISOString() });
});
app.use('/api/reminders', reminderRoutes); // Mount reminder routes// --- 404 Handler for unmatched routes ---
app.use((req, res, next) => {
res.status(404).json({ message: 'Not Found' });
});
// --- Global Error Handler ---// Catches errors passed via next(error)
app.use((err, req, res, next) => {
console.error("Unhandled Error:", err.stack || err);
// Avoid sending detailed errors in productionconst isDevelopment = process.env.NODE_ENV === 'development';
res.status(err.status || 500).json({
message: err.message || 'Internal Server Error',
// Optionally include stack trace ONLY in developmentstack: isDevelopment ? err.stack : undefined,
});
});
// --- Start Server and Services ---let server; // Variable to hold the server instance for graceful shutdownasyncfunctionstartServer() {
// Database initialization (if using DB)try {
// Check if using database by checking if DATABASE_PATH is set or default is used// This logic assumes you only initialize DB if you intend to use it.// Adjust this check based on your actual setup (e.g., an explicit config flag).const usingDb = process.env.DATABASE_PATH || fs.existsSync(path.resolve(dirname(fileURLToPath(import.meta.url)), 'db', 'reminders.sqlite'));
if (usingDb) {
awaitinitializeDatabase(); // Ensure DB is ready before starting scheduler/serverconsole.log('Database initialized successfully.');
} else {
console.log('Running without database persistence (using in-memory store).');
}
// Start the schedulerstartScheduler();
// Start the Express server
server = app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
console.log(`API endpoint available at http://localhost:${PORT}/api/reminders`);
console.log(`Health check available at http://localhost:${PORT}/health`);
});
// Handle graceful shutdown
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
} catch (error) {
console.error('Failed to start the application:', error);
process.exit(1);
}
}
functiongracefulShutdown() {
console.log('Received shutdown signal. Closing server and scheduler...');
stopScheduler(); // Stop the cron job from scheduling new checksif (server) {
server.close(async () => {
console.log('HTTP server closed.');
// Optional: Close database connection if applicabletry {
const db = awaitgetDb(); // Attempt to get DB instanceif (db && typeof db.close === 'function') { // Check if db exists and has close methodawait db.close();
console.log('Database connection closed.');
}
} catch (dbError) {
console.error('Error closing database connection:', dbError);
} finally {
console.log('Shutdown complete.');
process.exit(0); // Exit gracefully
}
});
} else {
console.log('No active server to close.');
process.exit(0);
}
// Force shutdown if server doesn't close within a timeoutsetTimeout(() => {
console.error('Could not close connections in time, forcing shutdown.');
process.exit(1);
}, 10000); // 10 seconds timeout
}
// Start the applicationstartServer();
Frequently asked questions
This tutorial provides a comprehensive guide to building an SMS reminder system using Node.js, Express, and the Infobip API. You'll learn how to set up the project, handle API requests, schedule reminders, and send SMS notifications reliably. The system uses node-cron for scheduling and offers options for persistent storage with SQLite or an in-memory store for simplicity.
The Infobip SMS API is the core communication component of the reminder system. It's used to send the actual SMS messages to users at the scheduled time. The Infobip Node.js SDK simplifies interaction with the API, handling authentication and requests.
Node-cron is a task scheduler for Node.js that allows you to define tasks to run at specific intervals, similar to cron jobs. This is essential for checking the database or in-memory store periodically for reminders that are due to be sent.
SQLite is recommended for production environments or any scenario where data persistence is required. If the server restarts, reminders stored in SQLite will be preserved, unlike the in-memory option where data is lost on restart.
While the tutorial uses SQLite as an example for persistent storage, you can adapt it to other databases. The core logic for interacting with the database is encapsulated in the reminderService.js file, allowing for flexibility in database choice.
You'll need an active Infobip account. The API key and base URL can be found in your Infobip portal. Create a .env file in your project's root directory and store these credentials there. Ensure the .env file is added to your .gitignore to prevent it from being committed to version control.
Express-validator is used to validate incoming API requests, ensuring that required fields like phone number, message, and schedule time are present and in the correct format. This adds robustness and security to the system.
The tutorial provides a basic regex for E.164 phone number format validation. For more comprehensive validation, consider using a specialized library like libphonenumber-js which can handle various international phone number formats and validation rules.
You should have Node.js and npm (or yarn) installed. An active Infobip account and API key are required. A basic understanding of REST APIs, asynchronous programming, and JavaScript is also recommended.
The project demonstrates basic error handling, including checking for configuration errors, catching database issues, logging Infobip API errors, and using a global error handler in Express to catch and format errors for appropriate responses.
Once the server is running, you can use tools like Postman or curl to send test requests to the API endpoint (/api/reminders). These tools allow you to send POST requests with the required data (phone number, message, and schedule time) and examine the responses.
After setting up the project, use npm start to run the application in production mode. For development, use npm run dev which automatically restarts the server whenever you make changes to the code. This requires installing nodemon as a development dependency.
The diagram illustrates the flow of data and interactions within the reminder system. It visually represents how client requests flow through the API, interact with the services, and eventually send messages via the Infobip API.
The project uses Node.js with Express for building the backend API and handling requests. Infobip's SMS API and Node.js SDK handle messaging, while node-cron schedules tasks. SQLite provides optional persistent storage, and express-validator manages request validation.
Storing schedule times in UTC (using toISOString()) ensures consistency and avoids issues related to time zones. This is especially important in reminder systems to ensure that messages are sent at the correct time, regardless of the server's location or the user's time zone.
This guide provides a step-by-step walkthrough for building a production-ready appointment reminder system using Node.js, Express, and the Infobip SMS API. We'll cover everything from project setup and core scheduling logic to robust error handling, security considerations, and deployment.
By the end of this tutorial, you will have a functional application capable of accepting reminder requests via an API, scheduling them, and reliably sending SMS notifications at the specified time using Infobip. This solves the common business need for automated, time-sensitive communication like appointment confirmations, payment due dates, or event reminders.
Last Updated: October 26, 2023
Project Overview and Goals
Goal: To create a backend service that can:
Problem Solved: Automating time-sensitive SMS notifications, reducing manual effort, and improving user engagement or adherence (e.g., reducing missed appointments).
Technologies Used:
node-cron: A simple cron-like job scheduler for Node.js, used to trigger checks for due reminders.dotenv: Loads environment variables from a.envfile.uuid: Generates unique IDs for reminders.express-validator: Middleware for request validation.System Architecture:
Placeholder: Insert a system architecture diagram image (e.g., PNG or SVG) here. The diagram should illustrate the flow from Client App -> Node.js/Express API -> Reminder Service -> Database, with the Reminder Service interacting with
node-cronand the Infobip Service, which in turn calls the Infobip API.Prerequisites:
curl).Final Outcome: A running Node.js application with an API endpoint to schedule SMS reminders, which are then automatically sent at the correct time via Infobip.
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir node-infobip-scheduler cd node-infobip-schedulerInitialize npm: Initialize the project using npm. The
-yflag accepts default settings.This creates a
package.jsonfile.Enable ES Modules: Since the code examples use
import/exportsyntax (ES Modules), add the following line to yourpackage.jsonfile:// package.json { // ... other settings "type": "module", // ... rest of the file }Install Dependencies: Install Express, the Infobip SDK,
node-cron,dotenv,uuid, andexpress-validator.Install Development Dependencies (Optional but Recommended): Install
nodemonfor automatic server restarts during development.(Optional) Install Database Driver: If using SQLite for persistence (recommended over in-memory for reliability):
npm install sqlite3 sqlite # sqlite provides async/await wrapperCreate Project Structure: Set up a basic folder structure for better organization:
Create
.gitignore: Create a.gitignorefile in the root directory to prevent sensitive files and unnecessary directories from being committed to version control.Create
.envFile: Create a.envfile in the root directory. This file will hold your Infobip credentials and other configuration. Remember to add.envto your.gitignorefile.# .env PORT=3000 INFOBIP_API_KEY=YOUR_INFOBIP_API_KEY INFOBIP_BASE_URL=YOUR_INFOBIP_BASE_URL # Find this in your Infobip dashboard/API docs # Optional: Database path if using SQLite DATABASE_PATH=./db/reminders.sqlitePORT: The port your Express server will listen on.INFOBIP_API_KEY: Your secret API key from Infobip.INFOBIP_BASE_URL: Your unique base URL provided by Infobip (check your dashboard).DATABASE_PATH: Path to the SQLite database file.How to find Infobip API Key and Base URL:
.envfile.Add npm Scripts: Update the
scriptssection in yourpackage.jsonfor easier starting and development:// package.json { // ... other settings "type": "module", // Ensure this is present "scripts": { "start": "node src/app.js", "dev": "nodemon src/app.js", "test": "echo \"Error: no test specified\" && exit 1" // Placeholder for tests }, // ... rest of the file }npm start: Runs the application using Node.npm run dev: Runs the application usingnodemonfor auto-restarts.2. Implementing Core Functionality
Now, let's implement the core logic: configuring Infobip, managing reminders, and setting up the scheduler.
Configure Infobip SDK (
src/config/infobip.js): Create a file to initialize the Infobip client instance.// src/config/infobip.js import { Infobip, AuthType } from '@infobip-api/sdk'; import dotenv from 'dotenv'; dotenv.config(); // Load environment variables if (!process.env.INFOBIP_BASE_URL || !process.env.INFOBIP_API_KEY) { console.error(""FATAL ERROR: Infobip API Key or Base URL not configured in .env file.""); process.exit(1); // Exit if essential config is missing } const infobipClient = new Infobip({ baseUrl: process.env.INFOBIP_BASE_URL, apiKey: process.env.INFOBIP_API_KEY, authType: AuthType.ApiKey, }); export default infobipClient;dotenv.infobipClientinstance.Setup Database (Optional - SQLite Example) (
src/db/database.jsandsrc/db/schema.sql): If using SQLite, set up the database connection and ensure the table exists.Schema Definition (
src/db/schema.sql):-- src/db/schema.sql CREATE TABLE IF NOT EXISTS reminders ( id TEXT PRIMARY KEY, phoneNumber TEXT NOT NULL, message TEXT NOT NULL, scheduleTime TEXT NOT NULL, -- Store as ISO 8601 string (UTC) status TEXT NOT NULL DEFAULT 'pending', -- 'pending', 'sent', 'failed' createdAt TEXT DEFAULT CURRENT_TIMESTAMP, updatedAt TEXT DEFAULT CURRENT_TIMESTAMP ); -- Optional: Trigger to update 'updatedAt' timestamp automatically 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; -- Optional: Index for faster querying of pending reminders by time CREATE INDEX IF NOT EXISTS idx_reminders_pending_scheduleTime ON reminders (status, scheduleTime) WHERE status = 'pending';Database Setup (
src/db/database.js): (Note: This code uses the ES Module pattern to get the directory path, replacing the CommonJS__dirname)// src/db/database.js import sqlite3 from 'sqlite3'; import { open } from 'sqlite'; import fs from 'fs'; import path, { dirname } from 'path'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; // ES Module equivalent for __dirname const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); dotenv.config(); // Use path.resolve to ensure the path is absolute or relative to the project root as intended const defaultDbPath = path.resolve(__dirname, 'reminders.sqlite'); const dbPath = process.env.DATABASE_PATH || defaultDbPath; const dbDir = path.dirname(dbPath); // Ensure the database directory exists if (!fs.existsSync(dbDir)) { try { fs.mkdirSync(dbDir, { recursive: true }); console.log(`Database directory created at: ${dbDir}`); } catch (error) { console.error(`Error creating database directory at ${dbDir}:`, error); process.exit(1); } } let db; async function initializeDatabase() { if (db) return db; // Return existing connection if already initialized try { db = await open({ filename: dbPath, driver: sqlite3.Database }); console.log('Connected to the SQLite database.'); // Read and execute schema SQL const schemaPath = path.resolve(__dirname, 'schema.sql'); const schemaSql = fs.readFileSync(schemaPath, 'utf8'); await db.exec(schemaSql); console.log('Database schema ensured.'); return db; } catch (error) { console.error('Error initializing SQLite database:', error); process.exit(1); // Exit if database cannot be initialized } } // Function to get the database instance async function getDb() { if (!db) { await initializeDatabase(); } return db; } export { getDb, initializeDatabase }; // Export initializeDatabase if needed elsewhereimport.meta.urlpattern to correctly determine the directory path in ES Modules.sqlitelibrary (which provides async/await support oversqlite3).schema.sqlfile and executes it to ensure thereminderstable and indexes exist on startup.Reminder Service (
src/services/reminderService.js): This service handles the business logic for creating, retrieving, and updating reminders.// src/services/reminderService.js import { v4 as uuidv4 } from 'uuid'; // Choose ONE implementation: Database or In-Memory // Option 1: Database (SQLite) - Recommended for persistence import { getDb } from '../db/database.js'; // Option 2: In-Memory (Simpler, data lost on restart) - Comment out DB import if using this // let reminders = []; // In-memory store import infobipClient from '../config/infobip.js'; // --- Database Implementation (SQLite) --- // Make sure the 'getDb' import above is active if using this section. // Comment out this entire section if using the In-Memory alternative below. async function addReminder(phoneNumber, message, scheduleTime) { const db = await getDb(); const id = uuidv4(); const isoScheduleTime = new Date(scheduleTime).toISOString(); // Ensure UTC ISO format // Basic validation (more robust validation in controller/middleware) if (isNaN(new Date(isoScheduleTime).getTime())) { throw new Error('Invalid scheduleTime format. Please use ISO 8601 format.'); } if (new Date(isoScheduleTime) <= new Date()) { throw new Error('Schedule time must be in the future.'); } try { const result = await db.run( 'INSERT INTO reminders (id, phoneNumber, message, scheduleTime) VALUES (?, ?, ?, ?)', [id, phoneNumber, message, isoScheduleTime] ); console.log(`Reminder added with ID: ${id}`); return { id, phoneNumber, message, scheduleTime: isoScheduleTime, status: 'pending' }; } catch (error) { console.error('Error adding reminder to DB:', error); throw new Error('Failed to schedule reminder.'); // Propagate generic error } } async function getDueReminders() { const db = await getDb(); const now = new Date().toISOString(); try { // Select pending reminders where scheduleTime is now or in the past const reminders = await db.all( ""SELECT * FROM reminders WHERE status = 'pending' AND scheduleTime <= ?"", [now] ); return reminders; } catch (error) { console.error('Error fetching due reminders:', error); return []; // Return empty array on error to prevent scheduler crash } } async function updateReminderStatus(id, status, errorInfo = null) { const db = await getDb(); try { // In a real app, you might store more detailed error info const updateSql = status === 'failed' ? ""UPDATE reminders SET status = ?, updatedAt = CURRENT_TIMESTAMP WHERE id = ?"" // Add error logging/storage here if needed : ""UPDATE reminders SET status = ?, updatedAt = CURRENT_TIMESTAMP WHERE id = ?""; await db.run(updateSql, [status, id]); console.log(`Reminder ${id} status updated to: ${status}`); if (status === 'failed' && errorInfo) { console.error(`Reminder ${id} failed:`, errorInfo); } } catch (error) { console.error(`Error updating reminder ${id} status to ${status}:`, error); // Decide if this error is critical enough to stop the scheduler or just log } } // --- End Database Implementation --- /* // --- In-Memory Implementation (Alternative - Simpler, but not persistent) --- // Uncomment this section and comment out the Database Implementation above if using this. // Ensure the 'getDb' import at the top is commented out. let reminders = []; // In-memory store async function addReminder(phoneNumber, message, scheduleTime) { const id = uuidv4(); const isoScheduleTime = new Date(scheduleTime).toISOString(); if (isNaN(new Date(isoScheduleTime).getTime())) { throw new Error('Invalid scheduleTime format. Please use ISO 8601 format.'); } if (new Date(isoScheduleTime) <= new Date()) { throw new Error('Schedule time must be in the future.'); } const newReminder = { id, phoneNumber, message, scheduleTime: isoScheduleTime, status: 'pending', createdAt: new Date().toISOString() }; reminders.push(newReminder); console.log(`Reminder added with ID: ${id}`); return newReminder; } async function getDueReminders() { const now = new Date(); return reminders.filter(r => r.status === 'pending' && new Date(r.scheduleTime) <= now); } async function updateReminderStatus(id, status, errorInfo = null) { const index = reminders.findIndex(r => r.id === id); if (index !== -1) { reminders[index].status = status; reminders[index].updatedAt = new Date().toISOString(); console.log(`Reminder ${id} status updated to: ${status}`); if (status === 'failed' && errorInfo) { console.error(`Reminder ${id} failed:`, errorInfo); } } else { console.warn(`Attempted to update status for non-existent reminder ID: ${id}`); } } // --- End In-Memory Implementation --- */ // --- Infobip Sending Logic (Used by both implementations) --- async function sendSms(phoneNumber, message) { console.log(`Attempting to send SMS to ${phoneNumber}`); try { const response = await infobipClient.channels.sms.send({ messages: [ { destinations: [{ to: phoneNumber }], from: 'InfoSMS', // Or your registered Sender ID / purchased number text: message, }, ], }); // Log success and potentially the message ID const sentMessageInfo = response.data.messages[0]; console.log(`SMS sent successfully to ${phoneNumber}. Message ID: ${sentMessageInfo?.messageId}, Status: ${sentMessageInfo?.status?.name}`); return { success: true, response: response.data }; } catch (error) { console.error(`Error sending SMS via Infobip to ${phoneNumber}:`, error.response?.data || error.message); // Extract meaningful error details if available from Infobip's response const errorDetails = error.response?.data?.requestError?.serviceException || { message: error.message }; // Ensure errorDetails is an object or string return { success: false, error: errorDetails }; } } // Export the functions relevant to the chosen implementation (DB or In-Memory) export { addReminder, getDueReminders, updateReminderStatus, sendSms };sendSmsfunction using the configuredinfobipClient.toISOString()).Scheduler Service (
src/services/schedulerService.js): This service usesnode-cronto periodically check for and process due reminders.// src/services/schedulerService.js import cron from 'node-cron'; import { getDueReminders, updateReminderStatus, sendSms } from './reminderService.js'; // Schedule a task to run every minute. Adjust the cron pattern as needed. // Pattern: second(opt) minute hour day(month) month day(week) // '* * * * *' = runs every minute const cronJob = cron.schedule('* * * * *', async () => { console.log(`[${new Date().toISOString()}] Running scheduler: Checking for due reminders...`); try { const dueReminders = await getDueReminders(); if (dueReminders.length === 0) { // console.log(`[${new Date().toISOString()}] No due reminders found.`); // Optional: reduce noise return; } console.log(`[${new Date().toISOString()}] Found ${dueReminders.length} due reminders. Processing...`); // Process reminders sequentially to avoid overwhelming downstream systems or rate limits for (const reminder of dueReminders) { console.log(`Processing reminder ID: ${reminder.id} for ${reminder.phoneNumber}`); const sendResult = await sendSms(reminder.phoneNumber, reminder.message); if (sendResult.success) { await updateReminderStatus(reminder.id, 'sent'); } else { // Basic retry could be added here, or just mark as failed const errorMessage = sendResult.error?.message || JSON.stringify(sendResult.error); console.error(`Failed to send reminder ID: ${reminder.id}. Error: ${errorMessage}`); await updateReminderStatus(reminder.id, 'failed', errorMessage); // Consider implementing a more robust retry mechanism (e.g., exponential backoff) // or notifying an admin system about the failure. } // Optional: Add a small delay between sends if needed to respect rate limits // await new Promise(resolve => setTimeout(resolve, 100)); } console.log(`[${new Date().toISOString()}] Finished processing batch of ${dueReminders.length} reminders.`); } catch (error) { // Catch errors during the fetching or processing loop console.error(`[${new Date().toISOString()}] CRITICAL ERROR in scheduler run:`, error); // Potentially add alerting here if the scheduler itself fails critically } }, { scheduled: false // Don't start automatically; we'll start it in app.js }); function startScheduler() { console.log('Starting scheduler service...'); cronJob.start(); } function stopScheduler() { console.log('Stopping scheduler service...'); cronJob.stop(); } export { startScheduler, stopScheduler };node-cronand the necessary functions fromreminderService.cronJob) that runs every minute (* * * * *). You can adjust this frequency based on your needs (e.g.,*/5 * * * *for every 5 minutes).startSchedulerandstopSchedulerfunctions to control the job lifecycle.3. Building the API Layer
We'll now create the Express API endpoint to accept requests for scheduling reminders.
Request Validation Middleware (
src/middleware/validator.js): Define validation rules usingexpress-validator.// src/middleware/validator.js import { body, validationResult } from 'express-validator'; const validateReminderRequest = [ // Validate phone number: basic check for E.164 format. // NOTE: This regex is a simplified check. For robust validation, // consider using a library like 'libphonenumber-js'. body('phoneNumber') .trim() .notEmpty().withMessage('Phone number is required.') .matches(/^\+?[1-9]\d{1,14}$/).withMessage('Invalid phone number format. Use E.164 format (e.g., +14155552671).'), // Validate message: ensure it's not empty and within reasonable length body('message') .trim() .notEmpty().withMessage('Message is required.') .isLength({ min: 1, max: 1600 }).withMessage('Message length must be between 1 and 1600 characters.'), // Max SMS length can vary // Validate scheduleTime: ensure it's a valid ISO 8601 date string body('scheduleTime') .notEmpty().withMessage('Schedule time is required.') .isISO8601({ strict: true, require_tld: false }).withMessage('Schedule time must be a valid ISO 8601 date string (e.g., 2024-12-31T10:00:00Z or 2024-12-31T10:00:00+01:00).') .custom((value) => { // Custom validation to ensure the date is in the future if (new Date(value) <= new Date()) { throw new Error('Schedule time must be in the future.'); } return true; }), // Middleware function to handle validation results (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { // Format errors for a cleaner response const formattedErrors = errors.array().map(err => ({ field: err.param, // Use 'param' instead of 'path' for body validation message: err.msg, value: err.value, })); return res.status(400).json({ errors: formattedErrors }); } next(); // Proceed if validation passes } ]; export { validateReminderRequest };body()to specify rules forphoneNumber,message, andscheduleTime.validationResultand returns a 400 error with details if validation fails.Reminder Controller (
src/controllers/reminderController.js): Handle the logic for the API endpoint.// src/controllers/reminderController.js import { addReminder } from '../services/reminderService.js'; const scheduleReminder = async (req, res, next) => { // Added next for error handling // Validation has already passed via middleware const { phoneNumber, message, scheduleTime } = req.body; try { const newReminder = await addReminder(phoneNumber, message, scheduleTime); // Respond with the created reminder details (excluding sensitive info if any) res.status(201).json({ message: 'Reminder scheduled successfully.', reminder: { id: newReminder.id, phoneNumber: newReminder.phoneNumber, // Consider masking if needed message: newReminder.message, scheduleTime: newReminder.scheduleTime, status: newReminder.status, } }); } catch (error) { // Handle errors from the service layer (e.g., DB error, invalid date logic) console.error('Error in scheduleReminder controller:', error); // Pass the error to the global error handler OR send a specific response // Option 1: Pass to global handler next(error); // Option 2: Send specific response (less consistent if you have a global handler) // res.status(500).json({ message: 'Failed to schedule reminder.', error: error.message }); } }; // Add other controllers here if needed (e.g., getReminderStatus, cancelReminder) export { scheduleReminder };addReminderfunction.req.body.Reminder Routes (
src/routes/reminderRoutes.js): Define the API routes and associate them with controllers and middleware.// src/routes/reminderRoutes.js import express from 'express'; import { scheduleReminder } from '../controllers/reminderController.js'; import { validateReminderRequest } from '../middleware/validator.js'; // Optional: Add authentication middleware here // import { authenticate } from '../middleware/auth.js'; const router = express.Router(); // POST /api/reminders - Schedule a new reminder // Optional: Add authentication middleware: router.post('/', authenticate, validateReminderRequest, scheduleReminder); router.post('/', validateReminderRequest, scheduleReminder); // Add other routes here (GET /:id, DELETE /:id, etc.) if needed export default router;POSTroute at/.validateReminderRequestmiddleware first, then thescheduleRemindercontroller.Express App Setup (
src/app.js): Configure the main Express application, load middleware, routes, and start the server and scheduler.// src/app.js import express from 'express'; import dotenv from 'dotenv'; import reminderRoutes from './routes/reminderRoutes.js'; import { startScheduler, stopScheduler } from './services/schedulerService.js'; // Import stopScheduler import { getDb, initializeDatabase } from './db/database.js'; // Import DB init and getDb // Load environment variables dotenv.config(); const app = express(); const PORT = process.env.PORT || 3000; // --- Middleware --- app.use(express.json()); // Parse JSON request bodies app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies // --- Basic Logging Middleware --- app.use((req, res, next) => { const start = Date.now(); res.on('finish', () => { // Log when response is finished const duration = Date.now() - start; console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`); }); next(); }); // --- Routes --- app.get('/health', (req, res) => { // Simple health check endpoint res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() }); }); app.use('/api/reminders', reminderRoutes); // Mount reminder routes // --- 404 Handler for unmatched routes --- app.use((req, res, next) => { res.status(404).json({ message: 'Not Found' }); }); // --- Global Error Handler --- // Catches errors passed via next(error) app.use((err, req, res, next) => { console.error("Unhandled Error:", err.stack || err); // Avoid sending detailed errors in production const isDevelopment = process.env.NODE_ENV === 'development'; res.status(err.status || 500).json({ message: err.message || 'Internal Server Error', // Optionally include stack trace ONLY in development stack: isDevelopment ? err.stack : undefined, }); }); // --- Start Server and Services --- let server; // Variable to hold the server instance for graceful shutdown async function startServer() { // Database initialization (if using DB) try { // Check if using database by checking if DATABASE_PATH is set or default is used // This logic assumes you only initialize DB if you intend to use it. // Adjust this check based on your actual setup (e.g., an explicit config flag). const usingDb = process.env.DATABASE_PATH || fs.existsSync(path.resolve(dirname(fileURLToPath(import.meta.url)), 'db', 'reminders.sqlite')); if (usingDb) { await initializeDatabase(); // Ensure DB is ready before starting scheduler/server console.log('Database initialized successfully.'); } else { console.log('Running without database persistence (using in-memory store).'); } // Start the scheduler startScheduler(); // Start the Express server server = app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); console.log(`API endpoint available at http://localhost:${PORT}/api/reminders`); console.log(`Health check available at http://localhost:${PORT}/health`); }); // Handle graceful shutdown process.on('SIGTERM', gracefulShutdown); process.on('SIGINT', gracefulShutdown); } catch (error) { console.error('Failed to start the application:', error); process.exit(1); } } function gracefulShutdown() { console.log('Received shutdown signal. Closing server and scheduler...'); stopScheduler(); // Stop the cron job from scheduling new checks if (server) { server.close(async () => { console.log('HTTP server closed.'); // Optional: Close database connection if applicable try { const db = await getDb(); // Attempt to get DB instance if (db && typeof db.close === 'function') { // Check if db exists and has close method await db.close(); console.log('Database connection closed.'); } } catch (dbError) { console.error('Error closing database connection:', dbError); } finally { console.log('Shutdown complete.'); process.exit(0); // Exit gracefully } }); } else { console.log('No active server to close.'); process.exit(0); } // Force shutdown if server doesn't close within a timeout setTimeout(() => { console.error('Could not close connections in time, forcing shutdown.'); process.exit(1); }, 10000); // 10 seconds timeout } // Start the application startServer();