code examples
code examples
Building Production-Ready SMS Scheduling & Reminders with Fastify and Infobip
A guide on creating a production-ready SMS scheduling and reminder system using Fastify, Infobip, Prisma, and Node.js, covering setup, implementation, and best practices.
Building Production-Ready SMS Scheduling & Reminders with Fastify and Infobip
This guide provides a complete walkthrough for building a robust SMS scheduling and reminder system using the Fastify web framework for Node.js and the Infobip communications platform. We will cover everything from project setup to deployment, focusing on production-readiness, best practices, and real-world considerations.
By the end of this tutorial, you will have a functional application capable of:
- Scheduling SMS messages to be sent at a specific future time.
- Retrieving details of scheduled messages.
- Rescheduling messages to a new time.
- Checking the status of scheduled messages (e.g., PENDING, CANCELED).
- Canceling scheduled messages before they are sent.
- Storing and managing scheduled job metadata in a database.
This system solves the common business need for automated, timed communication like appointment reminders, payment due date notifications, event follow-ups, and abandoned cart recovery messages, helping to reduce no-shows, improve engagement, and save manual effort.
Project Overview and Goals
We aim to build a backend service using Fastify that exposes a RESTful API for managing scheduled SMS messages via Infobip.
Technologies Used:
- Node.js: The runtime environment for our JavaScript backend (ES Module syntax).
- Fastify: A high-performance, low-overhead web framework for Node.js, chosen for its speed, extensibility, and developer-friendly features (like built-in validation and logging).
- Infobip API: The third-party service used for sending and managing SMS messages. Chosen for its global reach, reliability, and specific features for scheduling and managing bulk messages.
- Axios: A promise-based HTTP client for making requests to the Infobip API from our Node.js application.
- Prisma: A modern ORM (Object-Relational Mapper) for Node.js and TypeScript, used for database interactions (schema definition, migrations, type-safe queries). We'll use it with SQLite for simplicity in this guide, but it easily adapts to PostgreSQL, MySQL, etc.
- dotenv: A module to load environment variables from a
.envfile intoprocess.env. - pino: Fastify's default high-performance JSON logger.
- uuid: For generating unique identifiers (specifically bulk IDs).
- Jest/Vitest: A testing framework for writing unit and integration tests. (We'll provide examples).
- Docker: For containerizing the application for consistent deployment.
System Architecture:
+-------------+ +-----------------+ +-----------------+ +------------------+
| Client |------>| Fastify App |<----->| Prisma (ORM) |<----->| Database (SQLite)|
| (e.g., UI, | | (API Endpoints, | | (Data Access) | +------------------+
| Postman) | | Controllers, | +-----------------+
+-------------+ | Services) |
+--------|--------+
|
v
+-----------------+
| Infobip API |
| (SMS Scheduling)|
+-----------------+
- Client: Initiates requests to schedule, view, reschedule, or cancel SMS messages.
- Fastify App: Receives requests, validates input, interacts with the
InfobipServiceandDatabaseService. - InfobipService: Contains logic to interact with the Infobip API endpoints for scheduling and management.
- DatabaseService (via Prisma): Stores metadata about scheduled jobs (like the
bulkIdreturned by Infobip) and potentially links it to internal application data. - Infobip API: The external service that actually handles the SMS scheduling and sending.
- Database: Persists information about the scheduled jobs managed by our application.
Prerequisites:
- Node.js (v18 or later recommended) and npm/yarn installed.
- An active Infobip account (Sign up for free). You'll need your API Key and Base URL.
- Basic understanding of JavaScript (including ES Modules), Node.js, REST APIs, and asynchronous programming.
- A code editor (like VS Code).
- Optional: Docker Desktop installed for containerization.
- Optional: Postman or
curlfor testing API endpoints.
Final Outcome:
A deployable Fastify application with API endpoints to fully manage the lifecycle of scheduled SMS messages via Infobip, complete with logging, error handling, database persistence, and basic security considerations.
1. Setting up the Project
Let's initialize our Fastify project and set up the basic structure and dependencies.
Step 1: Initialize Node.js Project
Open your terminal and create a new project directory:
mkdir fastify-infobip-scheduler
cd fastify-infobip-scheduler
npm init -yStep 2: Configure package.json for ES Modules
Since we'll be using import/export syntax throughout the project, add "type": "module" to your package.json:
// package.json
{
"name": "fastify-infobip-scheduler",
"version": "1.0.0",
"description": "",
"main": "src/server.js",
"type": "module",
"scripts": {
// ... scripts will be added below ...
},
"keywords": [],
"author": "",
"license": "ISC"
// ... dependencies will be added below ...
}- Why: This tells Node.js to interpret
.jsfiles in this project as ES Modules, allowing the use ofimportandexportstatements natively.
Step 3: Install Fastify and Core Dependencies
npm install fastify @fastify/env axios prisma @prisma/client dotenv uuidfastify: The core framework.@fastify/env: For loading and validating environment variables.axios: For making HTTP requests to the Infobip API.prisma,@prisma/client: The Prisma CLI and runtime client.dotenv: Loads.envfile (though@fastify/envhandles this too,dotenvcan be useful for scripts like seeding).uuid: To generate unique bulk IDs for Infobip requests.
Step 4: Install Development Dependencies
npm install --save-dev nodemon concurrently pino-pretty @types/node ts-node typescript jest @types/jestnodemon: Automatically restarts the server during development on file changes.concurrently: Runs multiple commands concurrently (useful for watching files and running the server).pino-pretty: For human-readable logs during development (used only by thedevscript).typescript,ts-node,@types/node: Although we write JS, Prisma works best with TS tooling installed for generation.jest,@types/jest: Testing framework (as discussed in Section 13).
Step 5: Configure package.json Scripts
Add the following scripts to your package.json:
// package.json
{
// ... other configurations like name, version, type: "module" ...
"scripts": {
"dev": "nodemon src/server.js | pino-pretty",
"start": "node src/server.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"prisma:seed": "node prisma/seed.js",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
},
// ... dependencies and devDependencies ...
}- Note: The
testscript includesnode --experimental-vm-moduleswhich is often required for Jest to work correctly with ES Modules.
Step 6: Set up Project Structure
Create the following directories and files:
fastify-infobip-scheduler/
├── prisma/
│ ├── schema.prisma
│ └── seed.js
├── src/
│ ├── controllers/
│ │ └── smsController.js
│ ├── routes/
│ │ └── smsRoutes.js
│ ├── services/
│ │ ├── infobipService.js
│ │ └── databaseService.js
│ ├── config/
│ │ └── environment.js
│ ├── app.js
│ └── server.js
├── tests/
│ └── smsController.test.js # Example test file
├── .env
├── .env.template
├── .gitignore
└── package.json
Step 7: Create .gitignore
Create a .gitignore file in the root directory. Migration SQL files should be committed.
# .gitignore
node_modules/
.env
dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Prisma
# Do NOT ignore migration SQL files: prisma/migrations/*/*.sql
prisma/dev.db*
prisma/dev.db-journal* # Ignore SQLite journal files
# Test results / coverage
coverage/Step 8: Create .env.template and .env
Create .env.template with the required variables:
# .env.template
# Infobip Credentials (Get from your Infobip account dashboard)
INFOBIP_BASE_URL=your_infobip_base_url_here # e.g., xyz.api.infobip.com
INFOBIP_API_KEY=your_infobip_api_key_here
# Application Settings
PORT=3000
HOST=0.0.0.0
LOG_LEVEL=info
NODE_ENV=development # Set to 'production' in production
# Database (Using SQLite for this guide)
DATABASE_URL="file:./prisma/dev.db"
# Sentry DSN (Optional - for error tracking in Section 10)
# SENTRY_DSN=your_sentry_dsn_hereNow, create a .env file by copying .env.template and fill in your actual Infobip Base URL and API Key. Never commit your .env file to version control.
Step 9: Configure Environment Loading (src/config/environment.js)
This file defines the schema for @fastify/env to validate and load environment variables.
// src/config/environment.js
const environmentSchema = {
type: 'object',
required: ['INFOBIP_BASE_URL', 'INFOBIP_API_KEY', 'DATABASE_URL', 'PORT', 'HOST'],
properties: {
INFOBIP_BASE_URL: { type: 'string', description: 'Base URL for Infobip API' },
INFOBIP_API_KEY: { type: 'string', description: 'API Key for Infobip' },
DATABASE_URL: { type: 'string', description: 'Connection URL for the database' },
PORT: { type: 'number', default: 3000 },
HOST: { type: 'string', default: '0.0.0.0' },
LOG_LEVEL: { type: 'string', default: 'info' },
NODE_ENV: { type: 'string', enum: ['development', 'production', 'test'], default: 'development'},
SENTRY_DSN: { type: 'string', description: 'Sentry DSN for error tracking (Optional)' }
},
};
const options = {
confKey: 'config', // Access variables via `fastify.config`
schema: environmentSchema,
dotenv: true, // Load .env file
// data: process.env // Optional: Explicitly pass process.env if needed
};
export default options;- Why: Using
@fastify/envprovides automatic validation and type coercion for environment variables, preventing runtime errors due to misconfiguration. It centralizes environment management.
Step 10: Configure Prisma (prisma/schema.prisma)
Define the database schema. We'll create a simple table to track our scheduled jobs.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite" // Or postgresql, mysql, etc.
url = env("DATABASE_URL")
}
// Model to store information about scheduled SMS jobs
model ScheduledSmsJob {
id String @id @default(cuid()) // Unique job ID in our system
bulkId String @unique // The Bulk ID returned by Infobip
status String // e.g., PENDING, CANCELED, (Could be an enum later)
scheduledAt DateTime // The time the message is scheduled to be sent
recipient String // Store the recipient phone number
messageText String // Store the message content (optional, for reference)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add indexes for frequently queried fields
@@index([status])
@@index([scheduledAt])
}- Why: This schema allows us to map the Infobip
bulkIdto an internal record, track its intendedscheduledAttime,status(which we'll manage based on Infobip responses), and other relevant details like the recipient and message text for easier debugging or application logic.@uniqueonbulkIdensures we don't have duplicate entries for the same Infobip job. Added indexes onstatusandscheduledAtfor potential performance improvements.
Step 11: Initialize Prisma Database
Run the following command to create the initial database file and migration:
npx prisma migrate dev --name initThis will:
- Create the
prisma/migrationsdirectory containing the SQL migration file. - Create the
dev.dbSQLite file (or prompt for database creation if using another provider). - Apply the migration.
- Generate the Prisma Client (
@prisma/client).
You can inspect the database using Prisma Studio:
npx prisma studio2. Implementing Core Functionality (Services)
Now, let's create the services that encapsulate the logic for interacting with Infobip and our database.
Step 1: Implement Database Service (src/services/databaseService.js)
This service will handle all interactions with our ScheduledSmsJob table using Prisma Client.
// src/services/databaseService.js
import { PrismaClient } from '@prisma/client';
// Instantiate Prisma Client - it manages connection pooling
const prisma = new PrismaClient();
async function saveJob(data) {
try {
return await prisma.scheduledSmsJob.create({ data });
} catch (error) {
// Log the error appropriately using a proper logger in a real app
console.error('Error saving job to database:', error);
// Consider throwing a custom error class
throw new Error('Failed to save job metadata');
}
}
async function findJobByBulkId(bulkId) {
try {
return await prisma.scheduledSmsJob.findUnique({ where: { bulkId } });
} catch (error) {
console.error(`Error finding job by bulkId ${bulkId}:`, error);
throw new Error('Failed to retrieve job metadata');
}
}
async function updateJobStatus(bulkId, status, scheduledAt = undefined) {
try {
const updateData = { status, updatedAt: new Date() }; // Ensure updatedAt is updated
if (scheduledAt) {
updateData.scheduledAt = scheduledAt;
}
return await prisma.scheduledSmsJob.update({
where: { bulkId },
data: updateData,
});
} catch (error) {
// Prisma throws specific errors (e.g., P2025 for record not found)
// Handle these specifically if needed
console.error(`Error updating job status for bulkId ${bulkId}:`, error);
throw new Error('Failed to update job status');
}
}
// Graceful shutdown for Prisma Client
async function disconnectPrisma() {
await prisma.$disconnect();
}
export default {
prisma, // Export instance if needed elsewhere (e.g., health check)
saveJob,
findJobByBulkId,
updateJobStatus,
disconnectPrisma,
// Export other functions
};- Why: Encapsulating database logic in a service makes the code modular, testable, and easier to manage. It separates database concerns from API/controller logic. Includes basic error handling and exports the Prisma instance and a disconnect function for graceful shutdown.
Step 2: Implement Infobip Service (src/services/infobipService.js)
This service handles all communication with the Infobip API.
// src/services/infobipService.js
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
// Configuration will be injected via initializeInfobipService
let INFOBIP_BASE_URL;
let INFOBIP_API_KEY;
let infobipApi; // Axios instance
/**
* Initializes the Infobip service with configuration.
* Call this once during application startup (e.g., in app.js).
* Note: A dependency injection pattern (using Fastify decorators or a DI container)
* is generally preferred in larger applications over this explicit initialization.
*/
function initializeInfobipService(config) {
if (!config.INFOBIP_BASE_URL || !config.INFOBIP_API_KEY) {
throw new Error('Infobip Base URL and API Key must be configured.');
}
INFOBIP_BASE_URL = config.INFOBIP_BASE_URL;
INFOBIP_API_KEY = config.INFOBIP_API_KEY;
// Create Axios instance after config is loaded
infobipApi = axios.create();
// Add interceptors to the instance
infobipApi.interceptors.request.use((axiosConfig) => {
axiosConfig.baseURL = `https://${INFOBIP_BASE_URL}`;
axiosConfig.headers['Authorization'] = `App ${INFOBIP_API_KEY}`;
axiosConfig.headers['Content-Type'] = 'application/json';
axiosConfig.headers['Accept'] = 'application/json';
return axiosConfig;
}, (error) => {
return Promise.reject(error);
});
}
/**
* Schedules an SMS message for future delivery.
* IMPORTANT: Endpoint path and response structure must be verified against
* official Infobip API documentation. This implementation is based on common patterns.
* @param {string} recipient - E.164 format recommended.
* @param {string} messageText - SMS content.
* @param {string} sendAt - ISO 8601 format (e.g., ""2025-07-01T16:00:00.000Z"").
* @param {string} [sender=""InfoSMS""] - Sender ID (check regulations).
* @returns {Promise<{bulkId: string, status: string, sendAt: string, apiResponse: object}>}
*/
async function scheduleSms(recipient, messageText, sendAt, sender = 'InfoSMS') {
if (!infobipApi) throw new Error('Infobip service not initialized.');
const bulkId = `bulk-${uuidv4()}`; // Generate unique ID client-side
const payload = {
messages: [
{
destinations: [{ to: recipient }],
from: sender,
text: messageText,
},
],
bulkId: bulkId,
sendAt: sendAt,
};
const endpoint = '/sms/2/bulks'; // VERIFY THIS ENDPOINT PATH
console.info(`Scheduling SMS via Infobip: POST ${endpoint} for recipient ${recipient}`);
try {
const response = await infobipApi.post(endpoint, payload);
// --- VERIFICATION NEEDED ---
// Verify the exact structure of the success response from Infobip for scheduling.
// Does it return the bulkId? Does it return an initial status?
const returnedBulkId = response.data?.bulkId; // Example path, confirm!
const initialStatus = response.data?.status || 'PENDING'; // Example path/default, confirm!
if (!returnedBulkId || returnedBulkId !== bulkId) {
console.warn(`Infobip schedule response bulkId mismatch or missing. Sent: ${bulkId}, Received: ${returnedBulkId}`);
// Decide how to handle: throw error, use generated bulkId anyway? Using generated one for now.
}
return {
bulkId: bulkId, // Return the ID we generated and sent
status: initialStatus, // Use status from response if available, otherwise assume PENDING (NEEDS CONFIRMATION)
sendAt: sendAt,
apiResponse: response.data // Return full response for logging/debugging
};
} catch (error) {
const errorData = error.response?.data;
const errorMessage = errorData?.requestError?.serviceException?.text || error.message;
console.error(`Infobip API Error (scheduleSms - POST ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData);
throw new Error(`Failed to schedule SMS via Infobip: ${errorMessage}`);
}
}
/**
* Retrieves details of scheduled messages for a specific bulk ID.
* IMPORTANT: Endpoint path and response structure must be verified.
* @param {string} bulkId
* @returns {Promise<object>} - The bulk details from Infobip API response.
*/
async function getScheduledSms(bulkId) {
if (!infobipApi) throw new Error('Infobip service not initialized.');
if (!bulkId) throw new Error('Bulk ID is required.');
const endpoint = '/sms/2/bulks'; // VERIFY THIS ENDPOINT PATH
console.info(`Getting scheduled SMS via Infobip: GET ${endpoint}?bulkId=${bulkId}`);
try {
const response = await infobipApi.get(endpoint, { params: { bulkId } });
// --- VERIFICATION NEEDED ---
// Verify the response structure when querying by bulkId.
// Does it return an array `bulks`? Is the bulk data directly in `response.data`?
const bulks = response.data?.bulks; // Example path, confirm!
if (bulks && bulks.length > 0) {
return bulks[0]; // Return the first matching bulk
} else if (response.data?.bulkId === bulkId) {
// Alternative structure check: if the response IS the bulk object directly
return response.data;
} else {
throw new Error(`Scheduled bulk ${bulkId} not found in Infobip response.`);
}
} catch (error) {
const errorData = error.response?.data;
const errorMessage = errorData?.requestError?.serviceException?.text || error.message;
console.error(`Infobip API Error (getScheduledSms - GET ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData);
if (error.response?.status === 404 || errorMessage.toLowerCase().includes('not found')) {
throw new Error(`Scheduled bulk ${bulkId} not found.`);
}
throw new Error(`Failed to retrieve scheduled SMS ${bulkId}: ${errorMessage}`);
}
}
/**
* Reschedules a bulk of messages to a new time.
* IMPORTANT: Endpoint path and response structure must be verified.
* @param {string} bulkId
* @param {string} newSendAt - ISO 8601 format.
* @returns {Promise<object>} - The updated bulk info from Infobip API response.
*/
async function rescheduleSms(bulkId, newSendAt) {
if (!infobipApi) throw new Error('Infobip service not initialized.');
if (!bulkId || !newSendAt) throw new Error('Bulk ID and new SendAt time are required.');
const endpoint = '/sms/2/bulks'; // VERIFY THIS ENDPOINT PATH (often PUT /sms/2/bulks?bulkId=...)
const payload = { sendAt: newSendAt };
console.info(`Rescheduling SMS via Infobip: PUT ${endpoint}?bulkId=${bulkId}`);
try {
// Note: PUT might require bulkId in query params, check docs
const response = await infobipApi.put(endpoint, payload, { params: { bulkId } });
// --- VERIFICATION NEEDED ---
// Verify the success response structure for rescheduling.
// Does it return { bulkId, sendAt }?
return response.data;
} catch (error) {
const errorData = error.response?.data;
const errorMessage = errorData?.requestError?.serviceException?.text || error.message;
console.error(`Infobip API Error (rescheduleSms - PUT ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData);
if (error.response?.status === 404 || errorMessage.toLowerCase().includes('not found')) {
throw new Error(`Scheduled bulk ${bulkId} not found for rescheduling.`);
}
// Handle other errors e.g., trying to reschedule already sent bulk (might be 400/409)
throw new Error(`Failed to reschedule SMS ${bulkId}: ${errorMessage}`);
}
}
/**
* Retrieves the status of a scheduled bulk.
* IMPORTANT: Endpoint path and response structure must be verified.
* @param {string} bulkId
* @returns {Promise<object>} - Status info from Infobip API response (e.g., { bulkId, status }).
*/
async function getScheduledSmsStatus(bulkId) {
if (!infobipApi) throw new Error('Infobip service not initialized.');
if (!bulkId) throw new Error('Bulk ID is required.');
const endpoint = '/sms/2/bulks/status'; // VERIFY THIS ENDPOINT PATH
console.info(`Getting status via Infobip: GET ${endpoint}?bulkId=${bulkId}`);
try {
const response = await infobipApi.get(endpoint, { params: { bulkId } });
// --- VERIFICATION NEEDED ---
// Verify the response structure for status query.
// Does it return an array `bulks` with status?
const bulks = response.data?.bulks; // Example path, confirm!
if (bulks && bulks.length > 0) {
return bulks[0]; // Return status info { bulkId, status }
} else if (response.data?.bulkId === bulkId && response.data?.status) {
// Alternative structure check
return response.data;
} else {
throw new Error(`Scheduled bulk status for ${bulkId} not found in Infobip response.`);
}
} catch (error) {
const errorData = error.response?.data;
const errorMessage = errorData?.requestError?.serviceException?.text || error.message;
console.error(`Infobip API Error (getScheduledSmsStatus - GET ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData);
if (error.response?.status === 404 || errorMessage.toLowerCase().includes('not found')) {
throw new Error(`Scheduled bulk status for ${bulkId} not found.`);
}
throw new Error(`Failed to get scheduled SMS status for ${bulkId}: ${errorMessage}`);
}
}
/**
* Updates the status of a scheduled bulk (e.g., cancels it).
* IMPORTANT: Endpoint path and response structure must be verified.
* @param {string} bulkId
* @param {string} status - New status (e.g., ""CANCELED"").
* @returns {Promise<object>} - Updated status info from Infobip API response.
*/
async function updateScheduledSmsStatus(bulkId, status) {
if (!infobipApi) throw new Error('Infobip service not initialized.');
if (!bulkId || !status) throw new Error('Bulk ID and status are required.');
// Only support cancellation for now via this specific function
if (status.toUpperCase() !== 'CANCELED') {
throw new Error('Only cancellation (""CANCELED"") is supported via this status update function.');
}
const endpoint = '/sms/2/bulks/status'; // VERIFY THIS ENDPOINT PATH
const payload = { status: status.toUpperCase() }; // Infobip likely expects uppercase status
console.info(`Updating status via Infobip: PUT ${endpoint}?bulkId=${bulkId}`);
try {
// Note: PUT might require bulkId in query params, check docs
const response = await infobipApi.put(endpoint, payload, { params: { bulkId } });
// --- VERIFICATION NEEDED ---
// Verify the success response structure for status update.
// Does it return { bulkId, status }?
return response.data;
} catch (error) {
const errorData = error.response?.data;
const errorMessage = errorData?.requestError?.serviceException?.text || error.message;
console.error(`Infobip API Error (updateScheduledSmsStatus - PUT ${endpoint}): Status ${error.response?.status}, Message: ${errorMessage}`, errorData);
if (error.response?.status === 404 || errorMessage.toLowerCase().includes('not found')) {
throw new Error(`Scheduled bulk ${bulkId} not found for status update.`);
}
// Handle other errors e.g., trying to cancel already sent bulk (might be 400/409)
throw new Error(`Failed to update scheduled SMS status for ${bulkId}: ${errorMessage}`);
}
}
export default {
initializeInfobipService,
scheduleSms,
getScheduledSms,
rescheduleSms,
getScheduledSmsStatus,
updateScheduledSmsStatus,
};- Why: This service abstracts Infobip API interactions.
- Uses a centralized
axiosinstance with interceptors. - Maps functions to business actions and potential Infobip endpoints.
- Generates unique
bulkIdclient-side usinguuid. - Includes stronger comments emphasizing the need to verify Infobip endpoints and response structures against official documentation.
- Basic error handling logs Infobip errors and throws application errors.
- Uses an
initializefunction for simplicity, with notes on DI being a better pattern for larger apps.
- Uses a centralized
3. Building the API Layer (Routes and Controllers)
Let's expose the service functionality through Fastify routes and controllers.
Step 1: Create SMS Controller (src/controllers/smsController.js)
Handles requests, validates input, calls services, and formats responses.
// src/controllers/smsController.js
import infobipService from '../services/infobipService.js';
import databaseService from '../services/databaseService.js';
// --- Request Validation Schemas ---
// Suggestion: Use a library like libphonenumber-js for more robust validation in production
const phoneRegex = '^\\+[1-9]\\d{1,14}$'; // Basic E.164 check, anchored start/end
const scheduleSmsSchema = {
body: {
type: 'object',
required: ['recipient', 'messageText', 'sendAt'],
properties: {
recipient: {
type: 'string',
description: 'E.164 format required (e.g., +12223334444)',
pattern: phoneRegex, // Basic anchored E.164 check
errorMessage: 'Recipient must be a valid phone number in E.164 format (e.g., +12223334444)'
},
messageText: { type: 'string', minLength: 1, maxLength: 1600 }, // Check SMS length limits
sendAt: {
type: 'string',
format: 'date-time', // Validates ISO 8601 format
description: 'ISO 8601 format required (e.g., 2025-09-01T12:00:00.000Z)',
errorMessage: 'sendAt must be a valid ISO 8601 date-time string'
},
sender: { type: 'string', description: 'Optional sender ID' },
},
additionalProperties: false,
},
response: {
201: { // Use 201 Created for successful scheduling
description: 'SMS scheduled successfully',
type: 'object',
properties: {
message: { type: 'string' },
jobId: { type: 'string', description: 'Internal application job ID' },
bulkId: { type: 'string', description: 'Infobip bulk ID' },
status: { type: 'string', description: 'Initial status (e.g., PENDING)' },
scheduledAt: { type: 'string', format: 'date-time' },
},
},
// Add other response codes (e.g., 400, 500) using Fastify's standard error handling or setErrorHandler
},
};
const bulkIdParamsSchema = {
params: {
type: 'object',
required: ['bulkId'],
properties: {
bulkId: { type: 'string', description: 'The Infobip Bulk ID (usually starts with bulk-)' }
},
additionalProperties: false,
}
};
const rescheduleSmsSchema = {
...bulkIdParamsSchema, // Include params validation
body: {
type: 'object',
required: ['sendAt'],
properties: {
sendAt: {
type: 'string',
format: 'date-time',
description: 'New ISO 8601 scheduled time',
errorMessage: 'sendAt must be a valid ISO 8601 date-time string'
},
},
additionalProperties: false,
},
response: {
200: {
description: 'SMS rescheduled successfully',
type: 'object',
properties: {
message: { type: 'string' },
bulkId: { type: 'string' },
newScheduledAt: { type: 'string', format: 'date-time', description: 'Confirmed new schedule time from Infobip' },
},
},
},
};
const updateStatusSchema = {
...bulkIdParamsSchema, // Include params validation
body: {
type: 'object',
required: ['status'],
properties: {
status: {
type: 'string',
enum: ['CANCELED'], // Only allow canceling explicitly via this endpoint
description: 'Must be "CANCELED" to cancel the scheduled SMS',
errorMessage: 'Status must be "CANCELED"'
}
},
additionalProperties: false,
},
response: {
200: {
description: 'SMS status updated successfully',
type: 'object',
properties: {
message: { type: 'string' },
bulkId: { type: 'string' },
status: { type: 'string', description: 'Confirmed status from Infobip (e.g., CANCELED)' },
},
},
},
};
// --- Controller Functions ---
async function scheduleSmsHandler(request, reply) {
const { recipient, messageText, sendAt, sender } = request.body;
// Additional business logic validation (optional)
const scheduleDate = new Date(sendAt);
if (scheduleDate <= new Date()) {
return reply.code(400).send({ error: 'Bad Request', message: 'sendAt must be in the future.' });
}
// Add check for maximum schedule date if needed
try {
request.log.info(`Received schedule request for ${recipient} at ${sendAt}`);
// 1. Call Infobip to schedule
const infobipResponse = await infobipService.scheduleSms(recipient, messageText, sendAt, sender);
request.log.info(`Infobip scheduling initiated for bulkId: ${infobipResponse.bulkId}, initial status: ${infobipResponse.status}`);
// 2. Save job metadata to our database
const jobData = {
bulkId: infobipResponse.bulkId,
status: infobipResponse.status, // Use status from Infobip response
scheduledAt: new Date(sendAt), // Store as Date object
recipient: recipient,
messageText: messageText, // Store for reference
};
const savedJob = await databaseService.saveJob(jobData);
request.log.info(`Saved scheduled job metadata with internal ID: ${savedJob.id}`);
// 3. Return success response
return reply.code(201).send({
message: 'SMS scheduled successfully.',
jobId: savedJob.id,
bulkId: savedJob.bulkId,
status: savedJob.status,
scheduledAt: savedJob.scheduledAt.toISOString(),
});
} catch (error) {
request.log.error({ err: error }, `Error scheduling SMS for ${recipient}: ${error.message}`);
// Let Fastify's default error handler (or a custom one) handle the response
// It will typically return a 500 Internal Server Error
// You might want to check error types (e.g., Infobip vs DB errors) for more specific responses
throw error; // Re-throw for Fastify's error handling
}
}
// ... (Rest of the controller functions: getSmsHandler, rescheduleSmsHandler, getStatusHandler, cancelSmsHandler would go here) ...
// Export necessary items
export {
scheduleSmsSchema,
bulkIdParamsSchema,
rescheduleSmsSchema,
updateStatusSchema,
scheduleSmsHandler,
// Export other handlers...
};Frequently Asked Questions
How to schedule SMS messages with Fastify and Infobip?
You can schedule SMS messages by sending a POST request to the /sms/2/bulks endpoint of your Fastify application. This request should include the recipient's phone number, the message text, the desired send time (in ISO 8601 format), and optionally a sender ID. The application will then interact with the Infobip API to schedule the message.
What is the role of Prisma in SMS scheduling?
Prisma acts as an Object-Relational Mapper (ORM), simplifying database interactions. It is used to define the database schema, manage migrations, and execute type-safe queries. In this project, Prisma interacts with an SQLite database to store metadata about scheduled SMS jobs, but it's compatible with other databases like PostgreSQL and MySQL as well.
Why use Fastify for building an SMS scheduler?
Fastify is a high-performance Node.js web framework known for its speed, extensibility, and developer-friendly features. It offers built-in validation, logging, and a plugin-based architecture, making it a suitable choice for building robust and efficient backend services like this SMS scheduler.
When should I generate a unique bulk ID for Infobip?
A unique bulk ID, generated using the `uuid` library, should be created client-side before making the API call to Infobip to schedule a message. This `bulkId` helps to track and manage the scheduled message within your system and correlate it with Infobip's responses.
How to set up environment variables for the scheduler?
Create a `.env` file in the project's root directory. Store sensitive information like the Infobip API key and base URL, database URL, and port number. Use the `@fastify/env` module along with a schema defined in `src/config/environment.js` for secure loading and validation.
How to check the status of a scheduled SMS message?
You can retrieve the status of a scheduled message by sending a GET request to the `/sms/2/bulks/status` endpoint with the `bulkId` as a query parameter. The response from the Infobip API, routed through your Fastify app, will indicate whether the message is pending, canceled, or sent.
What data does the ScheduledSmsJob model store?
The `ScheduledSmsJob` model, managed via Prisma, stores key information about scheduled SMS jobs: a unique internal `id`, the Infobip `bulkId`, current `status`, `scheduledAt` timestamp, recipient phone number, message content, and timestamps for creation and updates.
How to integrate error tracking with Sentry?
While optional, Sentry integration is recommended for production applications. Include your Sentry DSN in the `.env` file and configure Sentry within your Fastify application to automatically capture and report errors, enhancing monitoring and debugging capabilities.
What is the system architecture of the SMS scheduler?
The system follows a client-server architecture where a client (e.g., UI or Postman) interacts with a Fastify backend application. The backend communicates with an Infobip API for sending and scheduling SMS messages and a database (SQLite) for storing scheduling metadata.
Can I reschedule an existing SMS message?
Yes, you can reschedule SMS messages through a PUT request to the `/sms/2/bulks` endpoint. Supply the `bulkId` and the new desired send time (`sendAt` in ISO 8601 format) in the request body.
How to manage scheduled SMS jobs in the database?
The `databaseService.js` file contains functions for interacting with the database. These functions, built upon Prisma, allow you to save job details, retrieve jobs by their `bulkId`, and update job status information.
What are the technology prerequisites for this guide?
You need Node.js (v18 or later is recommended), npm or yarn, an active Infobip account (signup is available online), basic understanding of JavaScript and RESTful APIs, a code editor, and optionally, Docker and Postman or cURL for testing.
Why is Axios used in this project?
Axios is a promise-based HTTP client used for making API requests to the Infobip platform from your Node.js application. It simplifies the process of sending HTTP requests and handling responses within the project.
How to use Pino for effective logging?
Pino is the high-performance logger employed within this project. It automatically formats logs in JSON for improved analysis and debugging. For human-readable logs during development, Pino Pretty can be used through npm scripts.