Reduce no-shows and enhance customer experience by automatically sending SMS appointment reminders. Missed appointments cost businesses time and revenue. A timely SMS reminder is a simple, effective way to ensure customers remember their commitments.
This guide provides a step-by-step walkthrough for building a robust SMS appointment reminder system using Node.js, Express, and the MessageBird API. We will cover everything from initial project setup to deployment and monitoring, creating a foundation for a production-ready application.
Project Overview and Goals
What We'll Build:
A Node.js web application using the Express framework that:
- Provides an API endpoint to accept appointment details (customer name, phone number, appointment date/time).
- Validates the phone number using MessageBird Lookup.
- Schedules an SMS reminder via the MessageBird API to be sent a configurable time (e.g., 3 hours) before the appointment.
- Stores appointment information in a database (MongoDB).
- Includes essential production considerations like error handling, logging, security, and basic monitoring.
Problem Solved: Automating appointment reminders to minimize costly no-shows and improve operational efficiency.
Technologies Used:
- Node.js: A JavaScript runtime for building server-side applications.
- Express: A minimal and flexible Node.js web application framework.
- MessageBird: A communications platform-as-a-service (CPaaS) for sending SMS, performing number lookups, and more.
- MongoDB: A NoSQL database for storing appointment data (using Mongoose ODM).
- Moment.js / Moment Timezone: Libraries for easy date/time manipulation and time zone handling.
- dotenv: For managing environment variables securely.
- express-validator: For robust input validation.
- winston: For flexible logging.
- express-rate-limit: For basic API rate limiting.
- Helmet: For securing Express apps with various HTTP headers.
Architecture:
graph LR
A[User/Client App] -- POST /api/appointments --> B(Node.js/Express App);
B -- Validate & Store --> C{MongoDB};
B -- Lookup Number --> D(MessageBird Lookup API);
B -- Schedule SMS --> E(MessageBird SMS API);
E -- Sends SMS at Scheduled Time --> F[Customer's Phone];
Prerequisites:
- Node.js and npm (or yarn) installed. Install Node.js. It's recommended to use recent LTS versions of Node.js (e.g., 18.x or 20.x) and the latest stable versions of the libraries mentioned.
- A MessageBird account and an API Key. Sign up for MessageBird.
- Access to a MongoDB instance (local or cloud-based like MongoDB Atlas). Install MongoDB or Use Atlas.
- Basic understanding of JavaScript, Node.js, REST APIs, and databases.
- A text editor or IDE (like VS Code).
- A tool for testing APIs (like
curl
or Postman).
Expected Outcome: A functional API endpoint capable of receiving appointment data, validating it, storing it, and scheduling an SMS reminder via MessageBird.
1. Setting up the Project
Let's initialize our Node.js project and install necessary dependencies.
Step 1: Create Project Directory and Initialize
Open your terminal and run:
mkdir messagebird-reminders
cd messagebird-reminders
npm init -y
This creates a package.json
file with default settings.
Step 2: Install Dependencies
npm install express messagebird dotenv moment moment-timezone mongoose express-validator winston express-rate-limit helmet
express
: Web framework.messagebird
: Official MessageBird Node.js SDK.dotenv
: Loads environment variables from a.env
file.moment
,moment-timezone
: Date/time handling and time zones.mongoose
: MongoDB object modeling tool.express-validator
: Input validation middleware.winston
: Logging library.express-rate-limit
: Basic rate limiting middleware.helmet
: Security middleware.
Step 3: Install Development Dependencies (Optional but Recommended)
npm install --save-dev nodemon
nodemon
: Automatically restarts the server during development when files change.
Step 4: Configure nodemon
(Optional)
Add a script to your package.json
for easy development startup. Update the main
entry point as well.
{
""name"": ""messagebird-reminders"",
""version"": ""1.0.0"",
""description"": """",
""main"": ""src/server.js"",
""scripts"": {
""start"": ""node src/server.js"",
""dev"": ""nodemon src/server.js"",
""test"": ""echo \""Error: no test specified\"" && exit 1""
},
""keywords"": [],
""author"": """",
""license"": ""ISC"",
""dependencies"": {
""dotenv"": ""^16.3.1"",
""express"": ""^4.18.2"",
""express-rate-limit"": ""^7.1.5"",
""express-validator"": ""^7.0.1"",
""helmet"": ""^7.1.0"",
""messagebird"": ""^4.0.1"",
""moment"": ""^2.29.4"",
""moment-timezone"": ""^0.5.43"",
""mongoose"": ""^8.0.3"",
""winston"": ""^3.11.0""
},
""devDependencies"": {
""nodemon"": ""^3.0.2""
}
}
Step 5: Set up Project Structure
Create the following directory structure:
messagebird-reminders/
├── config/
│ └── logger.js
├── models/
│ └── Appointment.js
├── routes/
│ └── api.js
├── controllers/
│ └── appointmentController.js
├── middleware/
│ ├── errorHandler.js
│ └── validation.js
├── src/
│ └── server.js
├── .env
├── .gitignore
└── package.json
config/
: Configuration files (e.g., logger setup).models/
: Database models (Mongoose schemas).routes/
: Express route definitions.controllers/
: Logic to handle requests (separating concerns from routes).middleware/
: Custom middleware functions (error handling, validation).src/
: Main application source code..env
: Stores environment variables (API keys, database URIs). Do not commit this file to Git..gitignore
: Specifies intentionally untracked files that Git should ignore (likenode_modules
,.env
).
Step 6: Create .gitignore
Create a .gitignore
file in the project root:
# .gitignore
# Dependencies
node_modules/
# Environment variables
.env
# Logs
logs/
*.log
# OS generated files
.DS_Store
Thumbs.db
Step 7: Create .env
File
Create a .env
file in the project root. Remember to replace placeholders with your actual credentials.
# .env
# Server Configuration
PORT=8080
NODE_ENV=development
# MessageBird Configuration
MESSAGEBIRD_API_KEY=YOUR_MESSAGEBIRD_LIVE_API_KEY
MESSAGEBIRD_ORIGINATOR=""YourBrand"" # Or a purchased Virtual Mobile Number e.g. +12025550144
DEFAULT_COUNTRY_CODE=US # Default ISO 3166-1 alpha-2 country code for number lookup
# MongoDB Configuration
MONGO_URI=mongodb://localhost:27017/reminderApp # Or your MongoDB Atlas connection string
# Reminder Configuration
REMINDER_HOURS_BEFORE=3 # How many hours before the appointment to send the reminder
BUSINESS_TIMEZONE=America/New_York # Default timezone for appointment interpretation
MESSAGEBIRD_API_KEY
: Get this from the MessageBird Dashboard -> Developers -> API access (REST). Use your live key.MESSAGEBIRD_ORIGINATOR
: This is the sender ID shown on the SMS. It can be an alphanumeric string (check country restrictions) or a virtual mobile number purchased from MessageBird.DEFAULT_COUNTRY_CODE
: Used by MessageBird Lookup to help parse local phone numbers. Use the code relevant to your primary audience.MONGO_URI
: Your MongoDB connection string.REMINDER_HOURS_BEFORE
: Defines when the reminder is sent relative to the appointment.BUSINESS_TIMEZONE
: Crucial for interpreting appointment times correctly if not explicitly provided with an offset. Use a valid IANA time zone name.
Step 8: Basic Server Setup
Create the main server file src/server.js
:
// src/server.js
require('dotenv').config(); // Load environment variables first
const express = require('express');
const helmet = require('helmet');
const mongoose = require('mongoose');
const logger = require('../config/logger');
const apiRoutes = require('../routes/api');
const errorHandler = require('../middleware/errorHandler');
const app = express();
const PORT = process.env.PORT || 8080;
// --- Database Connection ---
// Connect to MongoDB. Mongoose v6+ no longer requires deprecated options.
mongoose.connect(process.env.MONGO_URI)
.then(() => logger.info('MongoDB Connected successfully.'))
.catch(err => {
logger.error('MongoDB Connection Error:', err);
process.exit(1); // Exit if cannot connect to DB
});
// --- Middleware ---
app.use(helmet()); // Basic security headers
app.use(express.json()); // Parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies
// --- Routes ---
app.use('/api', apiRoutes); // Mount API routes
// Health check endpoint
app.get('/health', (req, res) => res.status(200).send('OK'));
// --- Error Handling ---
// Not found handler (404) - place after all routes
app.use((req, res, next) => {
res.status(404).json({ message: 'Resource not found' });
});
// Centralized error handler - place last
app.use(errorHandler);
// --- Start Server ---
app.listen(PORT, () => {
logger.info(`Server running on port ${PORT} in ${process.env.NODE_ENV} mode`);
});
module.exports = app; // Export for potential testing
This sets up Express, connects to MongoDB, applies basic middleware, defines a health check, sets up error handling placeholders, and starts the server.
2. Implementing Core Functionality
Now, let's build the logic for scheduling appointments.
Step 1: Define the Appointment Model
Create models/Appointment.js
to define the structure of appointment documents in MongoDB.
// models/Appointment.js
const mongoose = require('mongoose');
const appointmentSchema = new mongoose.Schema({
customerName: {
type: String,
required: true,
trim: true,
},
phoneNumber: { // Store the validated, international format number
type: String,
required: true,
trim: true,
},
appointmentTime: { // Store as UTC Date object
type: Date,
required: true,
},
reminderTime: { // Store as UTC Date object
type: Date,
required: true,
},
timeZone: { // Store the interpreted timezone for context
type: String,
required: true,
},
messageBirdMessageId: { // ID of the scheduled message from MessageBird
type: String,
required: false, // Might not get it immediately or if scheduling fails
},
status: { // Track status (e.g., scheduled, sent, failed)
type: String,
enum: ['scheduled', 'sending_failed', 'sent', 'canceled'],
default: 'scheduled',
},
createdAt: {
type: Date,
default: Date.now,
},
});
// Index appointmentTime for potential future querying
appointmentSchema.index({ appointmentTime: 1 });
appointmentSchema.index({ reminderTime: 1 });
appointmentSchema.index({ status: 1 });
const Appointment = mongoose.model('Appointment', appointmentSchema);
module.exports = Appointment;
Step 2: Create the Appointment Controller
Create controllers/appointmentController.js
to handle the business logic.
// controllers/appointmentController.js
const moment = require('moment-timezone');
const messagebird = require('messagebird')(process.env.MESSAGEBIRD_API_KEY);
const Appointment = require('../models/Appointment');
const logger = require('../config/logger');
const REMINDER_HOURS = parseInt(process.env.REMINDER_HOURS_BEFORE || '3', 10);
const BUSINESS_TIMEZONE = process.env.BUSINESS_TIMEZONE || 'UTC';
const DEFAULT_COUNTRY_CODE = process.env.DEFAULT_COUNTRY_CODE || null; // Pass null if not set
exports.scheduleAppointment = async (req, res, next) => {
const { customerName, phoneNumber, appointmentDate, appointmentTime, timeZone } = req.body;
// --- 1. Input Validation (Basic - Use express-validator for robust validation) ---
// We assume express-validator has already run (see Section 3)
// --- 2. Date and Time Processing ---
const effectiveTimeZone = timeZone || BUSINESS_TIMEZONE; // Use provided timezone or default
const dateTimeString = `${appointmentDate} ${appointmentTime}`;
let appointmentMoment;
try {
// Parse the date/time string using the specified timezone
appointmentMoment = moment.tz(dateTimeString, ""YYYY-MM-DD HH:mm"", effectiveTimeZone);
if (!appointmentMoment.isValid()) {
logger.warn(`Invalid date/time format received: ${dateTimeString} for timezone ${effectiveTimeZone}`);
return res.status(400).json({ message: 'Invalid date or time format. Use YYYY-MM-DD and HH:mm.' });
}
// Check if the appointment is in the future (add buffer, e.g., 5 mins for processing)
const minimumViableTime = moment().add(REMINDER_HOURS, 'hours').add(5, 'minutes');
if (appointmentMoment.isBefore(minimumViableTime)) {
logger.warn(`Appointment time too soon: ${appointmentMoment.format()} requested by ${phoneNumber}`);
return res.status(400).json({ message: `Appointment must be at least ${REMINDER_HOURS} hours and 5 minutes in the future.` });
}
} catch (error) {
logger.error(`Error parsing date/time: ${error.message}`, { dateTimeString, effectiveTimeZone });
return next(new Error('Failed to process appointment time.')); // Pass to central error handler
}
const reminderMoment = appointmentMoment.clone().subtract(REMINDER_HOURS, 'hours');
// --- 3. Phone Number Validation (Lookup API) ---
let validatedPhoneNumber;
try {
const lookupResponse = await new Promise((resolve, reject) => {
messagebird.lookup.read(phoneNumber, DEFAULT_COUNTRY_CODE, (err, response) => {
if (err) {
// Specific error for invalid number format
if (err.errors && err.errors[0].code === 21) {
logger.warn(`Invalid phone number format: ${phoneNumber}`, { error: err });
return reject({ status: 400, message: 'Invalid phone number format provided.' });
}
// Other lookup errors
logger.error(`MessageBird Lookup error for ${phoneNumber}:`, err);
return reject({ status: 500, message: 'Failed to validate phone number.' });
}
resolve(response);
});
});
// Check if the number is mobile (or potentially fixed-line-sms if needed)
if (lookupResponse.type !== 'mobile') {
logger.warn(`Non-mobile number provided: ${phoneNumber}, type: ${lookupResponse.type}`);
return res.status(400).json({ message: 'Please provide a valid mobile phone number for SMS reminders.' });
}
validatedPhoneNumber = lookupResponse.phoneNumber; // Use the normalized, international format
} catch (error) {
// Handle specific errors passed from the Promise rejection
if (error.status) {
return res.status(error.status).json({ message: error.message });
}
// Generic fallback
logger.error(`Unexpected error during phone validation: ${error.message}`, { phoneNumber });
return next(new Error('Failed during phone number validation.'));
}
// --- 4. Schedule SMS with MessageBird ---
// Note: Using MESSAGEBIRD_ORIGINATOR directly here. Consider a separate BUSINESS_NAME env var for more clarity if needed.
const messageBody = `${customerName}, this is a reminder for your appointment on ${appointmentMoment.format('MMM D, YYYY')} at ${appointmentMoment.format('h:mm A z')}. See you soon at ${process.env.MESSAGEBIRD_ORIGINATOR}!`;
let messageBirdResponse;
try {
messageBirdResponse = await new Promise((resolve, reject) => {
messagebird.messages.create({
originator: process.env.MESSAGEBIRD_ORIGINATOR,
recipients: [validatedPhoneNumber],
scheduledDatetime: reminderMoment.toISOString(), // Use ISO 8601 format
body: messageBody,
// reference: `appointment_${new Date().getTime()}` // Optional: Add your own reference
}, (err, response) => {
if (err) {
logger.error(`MessageBird scheduling error for ${validatedPhoneNumber}:`, err);
return reject(err); // Let the outer catch handle detailed logging/response
}
logger.info(`Successfully scheduled reminder for ${validatedPhoneNumber} at ${reminderMoment.format()}`, { messageId: response.id });
resolve(response);
});
});
} catch (error) {
logger.error(`Failed to schedule message via MessageBird: ${error.message}`, { phoneNumber: validatedPhoneNumber });
// Don't save appointment if scheduling failed crucial step
// Potentially implement retry logic here or flag for manual intervention
return next(new Error('Failed to schedule reminder SMS. Appointment not saved.'));
}
// --- 5. Store Appointment in Database ---
try {
const newAppointment = new Appointment({
customerName,
phoneNumber: validatedPhoneNumber, // Store validated number
appointmentTime: appointmentMoment.toDate(), // Store as native Date (UTC)
reminderTime: reminderMoment.toDate(), // Store as native Date (UTC)
timeZone: effectiveTimeZone, // Store the timezone used
messageBirdMessageId: messageBirdResponse?.id, // Store MessageBird message ID
status: 'scheduled',
});
await newAppointment.save();
logger.info(`Appointment saved successfully for ${validatedPhoneNumber}`, { id: newAppointment._id });
res.status(201).json({
message: 'Appointment scheduled successfully!',
appointmentId: newAppointment._id,
reminderTime: reminderMoment.format(),
messageBirdDetails: {
id: messageBirdResponse?.id,
status: messageBirdResponse?.recipients?.items[0]?.status // Initial status from MB
}
});
} catch (dbError) {
logger.error(`Database error saving appointment for ${validatedPhoneNumber}: ${dbError.message}`, { error: dbError });
// Critical: Scheduling succeeded, but DB save failed.
// Implement cleanup or alert: Potentially try to cancel the scheduled MessageBird message if possible,
// or flag this for manual review.
// For now, return an error indicating partial failure.
return next(new Error('Reminder scheduled, but failed to save appointment details. Please contact support.'));
}
};
This controller handles:
- Parsing and validating dates/times using
moment-timezone
. - Validating phone numbers using MessageBird Lookup.
- Scheduling the SMS via
messagebird.messages.create
withscheduledDatetime
. - Saving the appointment details to MongoDB.
- Basic error handling and logging.
3. Building a Complete API Layer
Let's define the API endpoint and add robust validation.
Step 1: Create API Routes
Create routes/api.js
:
// routes/api.js
const express = require('express');
const appointmentController = require('../controllers/appointmentController');
const { validateAppointment } = require('../middleware/validation');
const rateLimit = require('express-rate-limit');
const router = express.Router();
// Apply rate limiting to the API
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP, please try again after 15 minutes',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
router.use(apiLimiter);
// POST /api/appointments - Schedule a new appointment reminder
router.post(
'/appointments',
validateAppointment, // Apply validation middleware first
appointmentController.scheduleAppointment
);
// Add other API routes here if needed (e.g., GET /appointments, DELETE /appointments/:id)
module.exports = router;
Step 2: Implement Request Validation Middleware
Create middleware/validation.js
:
// middleware/validation.js
const { body, validationResult } = require('express-validator');
const moment = require('moment-timezone');
const logger = require('../config/logger');
exports.validateAppointment = [
// Validate customerName: not empty, trimmed
body('customerName')
.trim()
.notEmpty().withMessage('Customer name is required.'),
// Validate phoneNumber: not empty, basic check (more thorough check via Lookup)
body('phoneNumber')
.trim()
.notEmpty().withMessage('Phone number is required.'),
// .isMobilePhone('any', { strictMode: false }).withMessage('Invalid phone number format.'), // Optional: basic format check
// Validate appointmentDate: is date format YYYY-MM-DD
body('appointmentDate')
.isDate({ format: 'YYYY-MM-DD' }).withMessage('Invalid date format. Use YYYY-MM-DD.'),
// Validate appointmentTime: is time format HH:mm (24-hour)
body('appointmentTime')
.matches(/^([01]\d|2[0-3]):([0-5]\d)$/).withMessage('Invalid time format. Use HH:mm (24-hour).'),
// Validate timeZone: optional, but if provided, must be a valid IANA zone
body('timeZone')
.optional()
.custom((value) => {
if (!moment.tz.zone(value)) {
throw new Error('Invalid time zone provided.');
}
return true;
}),
// Middleware to handle validation results
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
logger.warn('Validation failed for /api/appointments', { errors: errors.array(), body: req.body });
return res.status(400).json({ errors: errors.array() });
}
next(); // Proceed to the controller if validation passes
},
];
This uses express-validator
to define rules for each expected field in the request body and returns a 400 error with details if validation fails.
Step 3: Testing the API Endpoint
You can now test the endpoint using curl
or Postman.
curl
Example:
(Make sure your server is running: npm run dev
)
curl -X POST http://localhost:8080/api/appointments \
-H ""Content-Type: application/json"" \
-d '{
""customerName"": ""Jane Doe"",
""phoneNumber"": ""+12025550125"",
""appointmentDate"": ""2025-06-15"",
""appointmentTime"": ""14:30"",
""timeZone"": ""America/New_York""
}'
Expected Success Response (201 Created):
{
""message"": ""Appointment scheduled successfully!"",
""appointmentId"": ""60a7c8d0e1b2c3a4b5d6e7f8"",
""reminderTime"": ""2025-06-15T11:30:00-04:00"",
""messageBirdDetails"": {
""id"": ""mb_message_id_string"",
""status"": ""scheduled""
}
}
Example Validation Error Response (400 Bad Request):
If you send invalid data (e.g., missing customerName
):
{
""errors"": [
{
""type"": ""field"",
""msg"": ""Customer name is required."",
""path"": ""customerName"",
""location"": ""body""
}
]
}
4. Integrating with Necessary Third-Party Services (MessageBird)
We've already integrated MessageBird, but let's recap the configuration and key acquisition.
-
API Key (
MESSAGEBIRD_API_KEY
):- Purpose: Authenticates your requests to the MessageBird API.
- How to Obtain:
- Log in to your MessageBird Dashboard.
- Navigate to Developers in the left-hand menu.
- Click on the API access (REST) tab.
- If you don't have a live key, click Add access key.
- Copy the generated Live API Key.
- Format: A string of alphanumeric characters (e.g.,
live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
). - Storage: Store securely in your
.env
file. Never commit it to version control.
-
Originator (
MESSAGEBIRD_ORIGINATOR
):- Purpose: The sender ID displayed on the recipient's phone.
- How to Obtain/Set:
- Alphanumeric: Choose a short brand name (e.g.,
""BeautyBird""
,""ClinicAppt""
). Check MessageBird's documentation for country-specific support and restrictions. Some countries (like the US) may not allow alphanumeric senders and require a registered number. - Virtual Mobile Number (VMN): Purchase a number from MessageBird (Dashboard -> Numbers -> Buy a number). This is often required for two-way communication or in restricted countries.
- Alphanumeric: Choose a short brand name (e.g.,
- Format: Either an alphanumeric string (up to 11 chars) or a phone number in E.164 format (e.g.,
+12025550144
). - Storage: Store in your
.env
file.
-
Default Country Code (
DEFAULT_COUNTRY_CODE
):- Purpose: Helps the MessageBird Lookup API correctly parse phone numbers provided without a country code (e.g.,
(202) 555-0125
instead of+12025550125
). - How to Obtain/Set: Use the ISO 3166-1 alpha-2 code for your primary target country (e.g.,
US
,GB
,NL
,AU
). Find codes here. - Format: Two-letter uppercase string.
- Storage: Store in your
.env
file.
- Purpose: Helps the MessageBird Lookup API correctly parse phone numbers provided without a country code (e.g.,
Fallback Mechanisms:
- Lookup Failure: The code currently handles lookup errors by returning a 400 or 500 status to the client. A more robust system might queue the request for a retry if the failure seems temporary (e.g., network issue vs. invalid number format).
- Scheduling Failure: If
messagebird.messages.create
fails, the appointment is not saved in the database. This prevents inconsistencies. Consider:- Implementing retry logic with exponential backoff for transient errors (e.g., network timeouts).
- Alerting administrators if scheduling fails consistently.
- MessageBird Service Outage: If MessageBird is down when scheduling, the request will fail. If MessageBird experiences issues after a message is successfully scheduled, the delivery relies on their infrastructure. Monitor MessageBird's status page during critical operations.
5. Implementing Proper Error Handling, Logging, and Retry Mechanisms
Step 1: Centralized Error Handling Middleware
Create middleware/errorHandler.js
:
// middleware/errorHandler.js
const logger = require('../config/logger');
const errorHandler = (err, req, res, next) => {
// Log the error internally
logger.error(`${err.status || 500} - ${err.message} - ${req.originalUrl} - ${req.method} - ${req.ip}`, {
error: {
message: err.message,
stack: err.stack,
status: err.status,
},
request: { // Optional: Log request details for debugging
body: req.body,
params: req.params,
query: req.query,
headers: req.headers,
}
});
// Determine status code - use err.status if available, otherwise default to 500
const statusCode = err.status || 500;
// Send generic error response to the client
// Avoid sending detailed error messages/stack traces in production
const responseMessage = (process.env.NODE_ENV === 'production' && statusCode === 500)
? 'An unexpected internal server error occurred.'
: err.message; // Send more details in development or for non-500 errors
res.status(statusCode).json({
message: responseMessage,
// Optionally include error code or type for client-side handling
// error_code: err.code || 'INTERNAL_ERROR'
});
};
module.exports = errorHandler;
This middleware catches errors passed via next(err)
, logs them, and sends a standardized JSON error response. It avoids leaking stack traces in production.
Step 2: Configure Logging
Create config/logger.js
using Winston:
// config/logger.js
const winston = require('winston');
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }), // Log stack traces
winston.format.splat(),
winston.format.json() // Log in JSON format
);
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', // More verbose in dev
format: logFormat,
defaultMeta: { service: 'reminder-service' },
transports: [
// Log errors to a separate file
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
// Log all levels to another file
new winston.transports.File({ filename: 'logs/combined.log' }),
],
});
// If we're not in production, also log to the console
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(), // Add colors
winston.format.simple() // Simple format for console
),
}));
}
module.exports = logger;
This sets up logging to files (logs/error.log
, logs/combined.log
) and to the console during development. Remember to create the logs
directory or ensure your deployment process does.
Step 3: Using the Logger
Import and use the logger in controllers and other modules as shown in appointmentController.js
and server.js
:
const logger = require('../config/logger');
logger.info('Informational message');
logger.warn('Warning message');
logger.error('Error message', { error: someErrorObject });
Step 4: Retry Mechanisms (Conceptual)
While retrying the scheduled message delivery is MessageBird's responsibility, you might retry certain steps within your controller:
- Database Connection: Mongoose handles some level of retry internally. Ensure your application exits gracefully if it can't establish an initial connection (as done in
server.js
). - MessageBird API Calls (Lookup/Scheduling): For transient errors (network issues, temporary MessageBird hiccups), implement a simple retry strategy.
Example Retry Logic Snippet (Conceptual - place inside catch
blocks):
// Conceptual retry logic within a catch block
let retries = 3;
let delay = 1000; // Start with 1 second delay
async function attemptAction() {
try {
// Perform the MessageBird API call or DB operation
// ... await messagebird.lookup.read(...) or await newAppointment.save()
} catch (error) {
if (isRetryableError(error) && retries > 0) {
retries--;
logger.warn(`Action failed, retrying in ${delay / 1000}s (${retries} retries left)`, { error });
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff
await attemptAction(); // Recursive call
} else {
logger.error('Action failed after retries or with non-retryable error', { error });
throw error; // Re-throw the error to be handled by the main error handler
}
}
}
function isRetryableError(error) {
// Define logic to check if the error suggests a temporary issue
// e.g., check error codes, status codes (5xx), network error types
const statusCode = error?.errors?.[0]?.code || error?.status;
// Simplistic example: retry on undefined status or 5xx errors
return statusCode === undefined || (typeof statusCode === 'number' && statusCode >= 500);
}
// Initial call within your main logic block where the action is performed
try {
await attemptAction();
} catch (finalError) {
// Handle final failure after retries (e.g., pass to next(finalError))
return next(finalError);
}
Important: Implement retries carefully to avoid infinite loops and excessive delays. Only retry errors that are likely temporary.
Testing Error Scenarios:
- Provide invalid input data to trigger validation errors.
- Temporarily use an invalid
MESSAGEBIRD_API_KEY
to test authentication errors. - Provide deliberately malformed phone numbers (
123
) to test Lookup error code 21. - Provide non-mobile numbers (if you know one) to test the
type !== 'mobile'
check. - Set an appointment time in the past or too close to the present.
- Temporarily stop your MongoDB instance to test database connection/save errors.
- (Harder) Simulate network interruptions or specific MessageBird 5xx errors (often requires mocking).
6. Creating a Database Schema and Data Layer
We defined the Mongoose schema in models/Appointment.js
(Section 2, Step 1).
- Schema Definition: Included fields for customer details, validated phone number, appointment/reminder times (as UTC Dates), timezone context, MessageBird message ID, and status.
- Data Access: Mongoose provides the data access layer. We use
new Appointment({...})
andawait newAppointment.save()
in the controller. - Migrations: For simple schema changes during development, dropping the collection might suffice. In production, use a migration tool (like
migrate-mongoose
or handle schema evolution within your application logic if possible) to manage changes without data loss. - Performance: Added indexes on
appointmentTime
,reminderTime
, andstatus
for efficient querying, especially if you need to find upcoming reminders or check statuses frequently.