This guide provides a complete walkthrough for building a production-ready application using Node.js and Express to schedule and send SMS reminders via the Vonage Messages API. We'll cover everything from initial project setup and Vonage configuration to core scheduling logic, API creation, error handling, database persistence, security, and deployment.
By the end of this tutorial, you will have a functional service capable of accepting requests to send an SMS message at a specific future time, reliably dispatching those messages, and handling potential issues gracefully.
Problem Solved: Automating SMS notifications, appointment reminders, follow-ups, or any communication that needs to be sent at a predetermined future time, without manual intervention.
Technologies Used:
- Node.js: A JavaScript runtime environment for building server-side applications.
- Express: A minimal and flexible Node.js web application framework used to build the API layer.
- Vonage Messages API: A powerful API for sending messages across various channels, including SMS. We'll use the
@vonage/server-sdk
Node.js library. node-cron
: A task scheduler based on cron syntax for scheduling the SMS sending jobs.dotenv
: A module to load environment variables from a.env
file.uuid
: To generate unique identifiers for scheduled jobs.- (Optional but Recommended for Production): A database (like PostgreSQL or MongoDB) and an ORM/ODM (like Prisma or Mongoose) for persistent job storage. This guide will initially use an in-memory store for simplicity and later detail database integration.
System Architecture:
graph LR
A[Client/User] -- HTTP POST /schedule --> B(Node.js/Express API);
B -- Validate & Store Job (In-Memory/DB) --> C{Scheduling Logic};
C -- Schedule Job (node-cron) --> D[Vonage Messages API];
D -- Send SMS --> E[Recipient's Phone];
B -- Job ID & Confirmation --> A;
C -- Update Job Status (In-Memory/DB) --> C;
F[Vonage Dashboard] -- Configure App & Number --> B;
G[Developer] -- Set .env Variables --> B;
style B fill:#f9f,stroke:#333,stroke-width:2px
style C fill:#ccf,stroke:#333,stroke-width:2px
style D fill:#f8d7da,stroke:#721c24,stroke-width:1px
Prerequisites:
- Node.js and npm (or yarn): Installed on your system. Download Node.js
- Vonage API Account: Sign up for a free account here. You'll get free credit to start.
- A Vonage Phone Number: Purchase one from the Vonage Dashboard (Numbers > Buy Numbers).
- Basic understanding of JavaScript, Node.js, and REST APIs.
- (Optional)
ngrok
: If you plan to test incoming features like delivery receipts later. Download ngrok
1. Project Setup and Initialization
Let's create our project directory, initialize Node.js, and install the necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for your project, then navigate into it.
mkdir node-sms-scheduler cd node-sms-scheduler
-
Initialize Node.js Project: This command creates a
package.json
file to manage your project's dependencies and scripts.npm init -y
-
Install Dependencies: We need Express for the web server, the Vonage SDK,
node-cron
for scheduling,dotenv
for environment variables, anduuid
for unique job IDs.npm install express @vonage/server-sdk node-cron dotenv uuid
-
Install Development Dependencies (Optional but Recommended):
nodemon
automatically restarts the server during development when files change.npm install --save-dev nodemon
-
Create Project Structure: Set up a basic structure for better organization.
mkdir src mkdir src/routes mkdir src/services mkdir src/config touch src/server.js touch src/routes/schedule.js touch src/services/smsScheduler.js touch src/config/vonageClient.js touch .env touch .gitignore
src/
: Contains all source code.src/routes/
: Holds API route definitions.src/services/
: Contains business logic, like scheduling and interacting with Vonage.src/config/
: For configuration files, like initializing the Vonage client.src/server.js
: The main entry point for the Express application..env
: Stores environment variables (API keys, etc.). Never commit this file..gitignore
: Specifies files/directories Git should ignore.
-
Configure
.gitignore
: Addnode_modules
and.env
to your.gitignore
file to prevent committing them.# .gitignore node_modules/ .env *.log
-
Add
start
anddev
Scripts topackage.json
: Modify thescripts
section in yourpackage.json
:// package.json (partial) "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", "test": "echo \"Error: no test specified\" && exit 1" },
npm start
: Runs the application using Node.npm run dev
: Runs the application usingnodemon
for development.
2. Vonage Account and Application Setup
Before writing code, we need to configure our Vonage account and create a Vonage Application.
-
Sign Up/Log In: Go to the Vonage API Dashboard and log in or sign up.
-
Get API Key and Secret: Your API Key and Secret are displayed at the top of the dashboard homepage. You'll need these for your
.env
file. -
Buy a Phone Number:
- Navigate to ""Numbers"" > ""Buy numbers"".
- Search for a number with SMS capabilities in your desired country.
- Purchase the number. Note down this number (in E.164 format, e.g.,
+15551234567
).
-
Set Default SMS API (Important):
- Navigate to your API Settings.
- Scroll down to ""SMS settings"".
- Ensure ""Default SMS Setting"" is set to Messages API. This ensures the SDK uses the correct endpoints and webhook formats (even though we aren't heavily relying on webhooks for sending scheduled messages, it's good practice).
- Click ""Save changes"".
-
Create a Vonage Application: Applications act as containers for your settings and credentials.
- Navigate to ""Applications"" > ""+ Create a new application"".
- Enter an Application Name (e.g., ""Node SMS Scheduler"").
- Click ""Generate public and private key"". A
private.key
file will download immediately. Save this file securely within your project directory (e.g., in the root or a dedicatedkeys
folder). Do not commit this key to version control. - Enable the ""Messages"" capability.
- For ""Inbound URL"" and ""Status URL"", you can leave these blank for this specific project if you only care about sending and don't need delivery receipts or inbound replies yet. If you wanted those features, you'd enter your webhook URLs (e.g.,
https://<your-ngrok-url>/webhooks/inbound
andhttps://<your-ngrok-url>/webhooks/status
). - Click ""Generate application"".
- You'll be redirected to the application details page. Copy the Application ID.
-
Link Your Number to the Application:
- On the application details page, scroll down to ""Link virtual numbers"".
- Find the Vonage number you purchased earlier and click ""Link"".
3. Environment Configuration
We'll use a .env
file to store sensitive credentials and configuration settings securely.
-
Populate
.env
File: Open the.env
file in your project root and add the following variables, replacing the placeholders with your actual values:# .env # Vonage Credentials VONAGE_API_KEY=YOUR_API_KEY VONAGE_API_SECRET=YOUR_API_SECRET VONAGE_APPLICATION_ID=YOUR_APPLICATION_ID VONAGE_PRIVATE_KEY_PATH=./private.key # Adjust path based on your project structure and deployment environment. VONAGE_NUMBER=+15551234567 # Your purchased Vonage number in E.164 format # Application Settings PORT=3000
VONAGE_API_KEY
,VONAGE_API_SECRET
: Found on your dashboard homepage.VONAGE_APPLICATION_ID
: Copied after creating the Vonage Application.VONAGE_PRIVATE_KEY_PATH
: The relative path from your project root to theprivate.key
file you downloaded. Ensure this file exists at the specified path relative to where the Node.js process starts.VONAGE_NUMBER
: The Vonage phone number you purchased and linked to the application.PORT
: The port your Express server will run on.
-
Load Environment Variables: At the very top of your main application file (
src/server.js
), require and configuredotenv
.// src/server.js require('dotenv').config(); // Load .env variables first const express = require('express'); // ... rest of the file
4. Implementing the Core Scheduling Logic
Now, let's set up the Vonage client and the service that handles scheduling.
-
Initialize Vonage Client: Create a reusable Vonage client instance.
// src/config/vonageClient.js const { Vonage } = require('@vonage/server-sdk'); const { Auth } = require('@vonage/auth'); const privateKey = process.env.VONAGE_PRIVATE_KEY_PATH; const applicationId = process.env.VONAGE_APPLICATION_ID; if (!privateKey || !applicationId) { console.error('Error: Vonage Application ID or Private Key path not found in environment variables.'); console.error('Please check your .env file and Vonage application setup.'); // In a real app, you might throw an error or exit gracefully // For simplicity here, we'll let it potentially fail later if used without keys } const credentials = new Auth({ applicationId: applicationId, privateKey: privateKey // SDK reads the file content from the path }); const vonage = new Vonage(credentials); module.exports = vonage;
-
Create the SMS Scheduling Service: This service will manage scheduled jobs (initially in memory) and use
node-cron
to trigger sending.// src/services/smsScheduler.js const cron = require('node-cron'); const { v4: uuidv4 } = require('uuid'); const vonage = require('../config/vonageClient'); // Import the configured client // --- In-Memory Store (Replace with Database for Production) --- const scheduledJobs = new Map(); // Stores { jobId: { task, details } } // ------------------------------------------------------------- /** * Converts a Date object into a cron expression string. * Example: new Date(2025, 3, 21, 10, 30, 0) -> ""0 30 10 21 4 *"" (Sec Min Hour Day Month DayOfWeek) * Note: Month is 0-indexed in Date(), but 1-indexed in cron. DayOfWeek 0=Sun. * @param {Date} date - The date/time to schedule the job. * @returns {string} Cron expression string. */ function dateToCron(date) { const minutes = date.getMinutes(); const hours = date.getHours(); const days = date.getDate(); const months = date.getMonth() + 1; // Cron months are 1-indexed // Day of week is not strictly needed if day and month are specified // const dayOfWeek = date.getDay(); // 0=Sunday, 6=Saturday return `${minutes} ${hours} ${days} ${months} *`; } /** * Sends the SMS using the Vonage Messages API. * @param {string} jobId - The ID of the job being processed. * @param {object} details - Job details ({ to, message, sendAt }). */ async function sendSms(jobId, details) { console.log(`[${new Date().toISOString()}] Sending SMS for Job ID: ${jobId}`); const { to, message } = details; const from = process.env.VONAGE_NUMBER; try { const resp = await vonage.messages.send({ message_type: ""text"", text: message, to: to, from: from, channel: ""sms"" }); console.log(`[${jobId}] SMS sent successfully. Message UUID: ${resp.message_uuid}`); // --- Update Job Status (In-Memory) --- if (scheduledJobs.has(jobId)) { scheduledJobs.get(jobId).details.status = 'sent'; scheduledJobs.get(jobId).details.messageUuid = resp.message_uuid; // No need to keep the cron task object after it runs delete scheduledJobs.get(jobId).task; } // --- (Add DB update logic here for production) --- } catch (err) { console.error(`[${jobId}] Error sending SMS:`, err?.response?.data || err.message || err); // --- Update Job Status (In-Memory) --- if (scheduledJobs.has(jobId)) { scheduledJobs.get(jobId).details.status = 'failed'; scheduledJobs.get(jobId).details.error = err?.response?.data?.title || err.message || 'Unknown error'; delete scheduledJobs.get(jobId).task; } // --- (Add DB update logic here for production) --- // Implement retry logic if necessary (see Error Handling section) } } /** * Schedules an SMS message to be sent at a specific time. * @param {string} to - Recipient phone number (E.164 format). * @param {string} message - The SMS message text. * @param {Date} sendAt - The Date object representing when to send. * @returns {string} The unique job ID. * @throws {Error} If sendAt is in the past or cron scheduling fails. */ function scheduleSms(to, message, sendAt) { if (!(sendAt instanceof Date) || isNaN(sendAt)) { throw new Error('Invalid sendAt date provided.'); } if (sendAt <= new Date()) { throw new Error('Schedule time must be in the future.'); } const jobId = uuidv4(); const cronTime = dateToCron(sendAt); const jobDetails = { jobId: jobId_ to: to_ message: message_ sendAt: sendAt.toISOString()_ status: 'pending'_ cronTime: cronTime_ createdAt: new Date().toISOString() }; console.log(`[${new Date().toISOString()}] Scheduling Job ID: ${jobId} at ${sendAt.toISOString()} (Cron: ${cronTime})`); try { // Schedule the job using node-cron const task = cron.schedule(cronTime_ () => { sendSms(jobId, jobDetails); // The task runs once, then we can potentially remove it from active scheduling // (though node-cron handles this internally if the time passes) // For cleanup, we update the status in sendSms. }, { scheduled: true, timezone: ""Etc/UTC"" // IMPORTANT: Schedule based on UTC }); // --- Store Job (In-Memory) --- scheduledJobs.set(jobId, { task, details: jobDetails }); // --- (Add DB storage logic here for production) --- console.log(`[${jobId}] Successfully scheduled.`); return jobId; } catch (error) { console.error(`[${jobId}] Failed to schedule cron job:`, error); throw new Error(`Failed to schedule SMS: ${error.message}`); } } /** * Retrieves the status of a scheduled job. * @param {string} jobId - The ID of the job to check. * @returns {object | null} Job details or null if not found. */ function getJobStatus(jobId) { const job = scheduledJobs.get(jobId); return job ? job.details : null; } /** * Cancels a pending scheduled job. * @param {string} jobId - The ID of the job to cancel. * @returns {boolean} True if cancelled successfully, false otherwise. */ function cancelJob(jobId) { const job = scheduledJobs.get(jobId); if (job && job.details.status === 'pending' && job.task) { try { job.task.stop(); // Stop the node-cron task job.details.status = 'cancelled'; delete job.task; // Remove reference to the stopped task console.log(`[${jobId}] Job cancelled successfully.`); // --- (Add DB update logic here for production) --- return true; } catch(error) { console.error(`[${jobId}] Error stopping cron task during cancellation:`, error); return false; } } console.log(`[${jobId}] Job not found or not in pending state.`); return false; } module.exports = { scheduleSms, getJobStatus, cancelJob // Export loadPendingJobs function when implemented };
- In-Memory Store:
scheduledJobs
map holds job data. This is lost on server restart. See Section 8 for database persistence. dateToCron
: Converts a JavaScriptDate
object into the specificcron
syntax needed bynode-cron
. It's crucial to schedule based on UTC (timezone: ""Etc/UTC""
) to avoid ambiguity.sendSms
: The function executed bynode-cron
. It calls the Vonage API using the configured client. Includes basic success/error logging and status updates (in-memory).scheduleSms
: The main function. Validates input, generates a unique ID, converts the date to a cron string, schedules thesendSms
function usingcron.schedule
, stores the job details and thecron
task object, and returns the ID.getJobStatus
,cancelJob
: Helper functions to check and cancel jobs (stops thenode-cron
task and updates status).
- In-Memory Store:
5. Building the API Layer with Express
Let's create the Express server and the API endpoint to receive scheduling requests.
-
Set up Basic Express Server:
// src/server.js require('dotenv').config(); const express = require('express'); const scheduleRoutes = require('./routes/schedule'); // Import routes 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 Health Check Route app.get('/health', (req, res) => { res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() }); }); // Mount Schedule Routes app.use('/api/schedule', scheduleRoutes); // Use the routes defined in schedule.js // --- Simple Error Handling Middleware (Add more robust handling later) --- app.use((err, req, res, next) => { console.error("Unhandled Error:", err.stack || err); res.status(err.status || 500).json({ success: false, error: err.message || 'Internal Server Error' }); }); // ------------------------------------------------------------------------ // --- 404 Handler for undefined routes --- app.use((req, res, next) => { res.status(404).json({ success: false, error: 'Not Found' }); }); // ------------------------------------------ app.listen(port, () => { console.log(`Server listening at http://localhost:${port}`); }); // Export the app instance for testing purposes (optional, depends on test setup) // module.exports = app;
- Loads
dotenv
. - Initializes Express.
- Uses middleware to parse JSON and URL-encoded request bodies.
- Includes a basic
/health
endpoint. - Mounts the scheduling routes under the
/api/schedule
path. - Includes basic 404 and global error handlers.
- Loads
-
Define Schedule Routes: Implement the API endpoints for scheduling, checking status, and cancelling.
// src/routes/schedule.js const express = require('express'); const { scheduleSms, getJobStatus, cancelJob } = require('../services/smsScheduler'); const router = express.Router(); // POST /api/schedule - Schedule a new SMS router.post('/', (req, res, next) => { const { to, message, sendAt } = req.body; // --- Basic Input Validation --- if (!to || !message || !sendAt) { return res.status(400).json({ success: false, error: 'Missing required fields: to, message, sendAt (ISO 8601 format)' }); } // Validate 'to' format (basic check - improve with a library for production) if (!/^\+?[1-9]\d{1,14}$/.test(to)) { return res.status(400).json({ success: false, error: 'Invalid phone number format. Use E.164 (e.g., +15551234567).' }); } const sendAtDate = new Date(sendAt); if (isNaN(sendAtDate)) { return res.status(400).json({ success: false, error: 'Invalid date format for sendAt. Use ISO 8601 (e.g., 2025-12-31T23:59:59Z).' }); } // ----------------------------- try { const jobId = scheduleSms(to, message, sendAtDate); res.status(202).json({ success: true, jobId: jobId, message: 'SMS scheduled successfully.' }); } catch (error) { // Handle specific errors from the scheduler service if (error.message.includes('future') || error.message.includes('Invalid sendAt')) { return res.status(400).json({ success: false, error: error.message }); } // Pass other errors to the global error handler next(error); } }); // GET /api/schedule/:jobId - Get job status router.get('/:jobId', (req, res) => { const { jobId } = req.params; const jobDetails = getJobStatus(jobId); if (jobDetails) { res.status(200).json({ success: true, job: jobDetails }); } else { res.status(404).json({ success: false, error: 'Job not found.' }); } }); // DELETE /api/schedule/:jobId - Cancel a scheduled job router.delete('/:jobId', (req, res, next) => { const { jobId } = req.params; try { const cancelled = cancelJob(jobId); if (cancelled) { res.status(200).json({ success: true, message: 'Job cancelled successfully.' }); } else { // Could be not found or already processed/failed/cancelled const jobDetails = getJobStatus(jobId); // Check current status if (!jobDetails) { return res.status(404).json({ success: false, error: 'Job not found.' }); } else { return res.status(400).json({ success: false, error: `Job cannot be cancelled (status: ${jobDetails.status}).` }); } } } catch (error) { // Pass potential errors from cancelJob (though unlikely with current code) next(error); } }); module.exports = router;
- Uses an Express
Router
. POST /
: Handles scheduling requests. Performs basic validation onto
,message
, andsendAt
. ConvertssendAt
(expected in ISO 8601 format) to aDate
object. CallsscheduleSms
and returns thejobId
with a202 Accepted
status. Includes specific error handling for validation failures.GET /:jobId
: Retrieves job status usinggetJobStatus
. Returns404
if not found.DELETE /:jobId
: Attempts to cancel a job usingcancelJob
. Returns appropriate status codes based on success, failure, or job status.
- Uses an Express
6. Integrating Vonage (Sending Logic)
This part was largely covered in src/services/smsScheduler.js
within the sendSms
function. Key points:
- Client Initialization: The pre-configured Vonage client from
src/config/vonageClient.js
is used. - Sending Method:
vonage.messages.send()
is used with the required parameters:message_type: ""text""
text
: The message content.to
: Recipient number.from
: Your Vonage number (from.env
).channel: ""sms""
- Response Handling: The
try...catch
block handles both successful responses (logging themessage_uuid
) and errors (logging details from the error response). - Status Update: The in-memory
scheduledJobs
map is updated to reflect'sent'
or'failed'
status.
7. Error Handling, Logging, and Retries
Robust error handling is critical for a reliable scheduling system.
-
Consistent Error Handling Strategy:
- API Layer (
src/routes/schedule.js
): Validate inputs early and return specific400 Bad Request
errors. Usetry...catch
around service calls. Handle known errors gracefully (like scheduling in the past). Pass unknown errors to the global Express error handler usingnext(error)
. - Service Layer (
src/services/smsScheduler.js
): Usetry...catch
aroundnode-cron
scheduling and Vonage API calls. Log errors with context (likejobId
). Update job status to'failed'
and store error information. Throw errors for critical failures (like invalid input date) to be caught by the API layer. - Global Error Handler (
src/server.js
): A final catch-all for unexpected errors, logging the stack trace and returning a generic500 Internal Server Error
response.
- API Layer (
-
Logging:
- Current: Basic
console.log
andconsole.error
are used. - Production Recommendation: Use a structured logging library like
winston
orpino
.- Configure different log levels (info, warn, error).
- Output logs in JSON format for easier parsing by log aggregation systems (like ELK Stack, Datadog, Splunk).
- Include contextual information in logs:
jobId
,timestamp
, relevant data. - Log key events: job scheduled, job execution started, SMS sent attempt, SMS sent success/failure (with Vonage
message_uuid
or error details), job cancelled.
Example (Conceptual Winston Setup):
npm install winston
// src/config/logger.js (Example) const winston = require('winston'); const logger = winston.createLogger({ level: 'info', // Log info and above format: winston.format.combine( winston.format.timestamp(), winston.format.json() // Output logs as JSON ), transports: [ // Write all logs with level `error` and below to `error.log` new winston.transports.File({ filename: 'error.log', level: 'error' }), // Write all logs with level `info` and below to `combined.log` new winston.transports.File({ filename: 'combined.log' }), ], }); // If we're not in production then log to the `console` with the format: // `${info.level}: ${info.message} JSON.stringify({ ...rest }) ` if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.simple(), })); } module.exports = logger; // Usage in other files: // const logger = require('./config/logger'); // logger.info(`[${jobId}] Scheduling job...`, { details }); // logger.error(`[${jobId}] Failed to send SMS`, { error: err.message });
- Current: Basic
-
Retry Mechanisms:
- Vonage Internal Retries: Vonage often handles transient network issues for message delivery internally.
- Application-Level Retries (for Vonage API Calls): For specific, potentially temporary Vonage API errors (e.g., network timeouts,
5xx
errors from Vonage), you could implement a simple retry within thesendSms
function.
Example (Simple Retry Logic in
sendSms
):// Inside services/smsScheduler.js -> sendSms function async function sendSms(jobId, details) { // ... (get details, from number) const MAX_RETRIES = 3; const INITIAL_DELAY_MS = 1000; // 1 second for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { const resp = await vonage.messages.send({ /* ... params ... */ }); console.log(`[${jobId}] Attempt ${attempt}: SMS sent successfully. UUID: ${resp.message_uuid}`); // Update status to 'sent' and exit loop // ... (update logic) ... return; // Success! } catch (err) { console.error(`[${jobId}] Attempt ${attempt} failed:`_ err?.response?.data || err.message); const statusCode = err?.response?.status; // Decide if retryable (e.g._ network errors_ server errors from Vonage) const isRetryable = !statusCode || (statusCode >= 500 && statusCode <= 599); if (isRetryable && attempt < MAX_RETRIES) { const delay = INITIAL_DELAY_MS * Math.pow(2_ attempt - 1); // Exponential backoff console.log(`[${jobId}] Retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } else { console.error(`[${jobId}] Final attempt failed or error is not retryable.`); // Update status to 'failed' // ... (update logic) ... return; // Stop retrying } } } }
- Scheduling Retries (Missed Jobs): The biggest challenge with the in-memory store is losing jobs on restart. A database (Section 8) is essential. With a DB, on application startup, you query for jobs that are
'pending'
but whosesendAt
time has passed, and either send them immediately or reschedule them slightly in the future. You also need to reschedule jobs whosesendAt
is still in the future.
8. Creating a Database Schema and Data Layer (Production Enhancement)
Using an in-memory store (scheduledJobs
map) is not suitable for production as all scheduled jobs are lost when the server restarts. A database is essential for persistence. We'll outline using PostgreSQL with Prisma as an example.
-
Install Prisma:
npm install prisma @prisma/client --save-dev
-
Initialize Prisma:
npx prisma init --datasource-provider postgresql
This creates a
prisma
directory with aschema.prisma
file and updates your.env
with aDATABASE_URL
variable. -
Configure Database Connection: Update the
DATABASE_URL
in your.env
file to point to your PostgreSQL database. Example:DATABASE_URL=""postgresql://user:password@host:port/database?schema=public""
-
Define Database Schema (
prisma/schema.prisma
):// prisma/schema.prisma generator client { provider = ""prisma-client-js"" } datasource db { provider = ""postgresql"" url = env(""DATABASE_URL"") } model ScheduledSms { id String @id @default(uuid()) // Use String for UUID to_number String message_text String send_at DateTime // Store in UTC status String @default(""pending"") // pending, sent, failed, cancelled vonage_message_id String? // Store the UUID from Vonage on success error_message String? // Store error details on failure cron_expression String? // Store the calculated cron string for reference created_at DateTime @default(now()) updated_at DateTime @updatedAt @@index([status, send_at]) // Index for finding pending jobs }
-
Apply Schema to Database (Migration):
npx prisma migrate dev --name init_scheduled_sms
This command creates the SQL migration file and applies it to your database, creating the
ScheduledSms
table. -
Generate Prisma Client:
npx prisma generate
This generates the typed database client in
node_modules/@prisma/client
. -
Update
smsScheduler.js
to Use Prisma:// src/services/smsScheduler.js const cron = require('node-cron'); // ... (other imports like uuid, vonageClient) const { PrismaClient } = require('@prisma/client'); // Import Prisma Client const prisma = new PrismaClient(); // Instantiate Prisma Client // --- Remove In-Memory Store --- // const scheduledJobs = new Map(); // ----------------------------- // ... (dateToCron function remains the same) ... async function sendSms(jobId, details) { // ... (logic to get 'to', 'message', 'from') ... try { const resp = await vonage.messages.send({ /* ... */ }); console.log(`[${jobId}] SMS sent successfully. UUID: ${resp.message_uuid}`); // --- Update Job Status (Database) --- await prisma.scheduledSms.update({ where: { id: jobId }, data: { status: 'sent', vonage_message_id: resp.message_uuid, updated_at: new Date() } }); // --- Stop tracking the cron task locally if needed (see below) --- } catch (err) { console.error(`[${jobId}] Error sending SMS:`, err?.response?.data || err.message); const errorMessage = err?.response?.data?.title || err.message || 'Unknown error'; // --- Update Job Status (Database) --- await prisma.scheduledSms.update({ where: { id: jobId }, data: { status: 'failed', error_message: errorMessage, updated_at: new Date() } }); // --- Stop tracking the cron task locally if needed --- } } // --- Need a way to manage active cron tasks in memory --- // This map stores the running node-cron task objects, keyed by jobId const activeCronTasks = new Map(); // ------------------------------------------------------- async function scheduleSms(to, message, sendAt) { if (!(sendAt instanceof Date) || isNaN(sendAt)) { throw new Error('Invalid sendAt date provided.'); } if (sendAt <= new Date()) { throw new Error('Schedule time must be in the future.'); } const cronTime = dateToCron(sendAt); const jobDetails = { // id will be generated by DB to_number: to_ message_text: message_ send_at: sendAt_ // Store as Date object for Prisma status: 'pending'_ cron_expression: cronTime // createdAt/updatedAt handled by Prisma }; // --- Store Job (Database) --- const savedJob = await prisma.scheduledSms.create({ data: jobDetails }); const jobId = savedJob.id; // Get the ID generated by the database // -------------------------- console.log(`[${new Date().toISOString()}] Scheduling Job ID: ${jobId} at ${sendAt.toISOString()} (Cron: ${cronTime})`); try { const task = cron.schedule(cronTime_ () => { // Pass necessary details to sendSms sendSms(jobId, { to: savedJob.to_number, message: savedJob.message_text }); // Once the task runs, remove it from the active map activeCronTasks.delete(jobId); }, { scheduled: true, timezone: ""Etc/UTC"" }); // --- Store active cron task reference --- activeCronTasks.set(jobId, task); // -------------------------------------- console.log(`[${jobId}] Successfully scheduled.`); return jobId; } catch (error) { console.error(`[${jobId}] Failed to schedule cron job:`, error); // --- Rollback DB entry or mark as failed? --- // Consider updating the status to 'failed_scheduling' in DB await prisma.scheduledSms.update({ where: { id: jobId }, data: { status: 'failed_scheduling', error_message: error.message } }); // ------------------------------------------ throw new Error(`Failed to schedule SMS: ${error.message}`); } } async function getJobStatus(jobId) { // --- Retrieve Job Status (Database) --- const job = await prisma.scheduledSms.findUnique({ where: { id: jobId } }); return job; // Returns the job object or null if not found // ------------------------------------ } async function cancelJob(jobId) { // --- Check Database First --- const job = await prisma.scheduledSms.findUnique({ where: { id: jobId }, select: { status: true } // Only fetch status }); if (!job) { console.log(`[${jobId}] Job not found in database.`); return false; // Job doesn't exist } if (job.status !== 'pending') { console.log(`[${jobId}] Job not in pending state (status: ${job.status}). Cannot cancel.`); return false; // Already processed or cancelled } // -------------------------- // --- Stop Active Cron Task (In-Memory) --- const task = activeCronTasks.get(jobId); if (task) { try { task.stop(); activeCronTasks.delete(jobId); // Remove from active tasks map console.log(`[${jobId}] Cron task stopped.`); } catch (error) { console.error(`[${jobId}] Error stopping cron task during cancellation:`, error); // Proceed to update DB status anyway? Or return false? Depends on requirements. // Let's proceed to update DB for consistency. } } else { console.warn(`[${jobId}] No active cron task found in memory map for pending job. It might have been lost on restart.`); // Still proceed to update DB status to cancelled. } // --------------------------------------- // --- Update Job Status (Database) --- await prisma.scheduledSms.update({ where: { id: jobId }, data: { status: 'cancelled', updated_at: new Date() } }); console.log(`[${jobId}] Job status updated to cancelled in database.`); return true; // ---------------------------------- } // --- Function to load and reschedule pending jobs on startup --- async function loadAndReschedulePendingJobs() { console.log('Loading and rescheduling pending jobs...'); const pendingJobs = await prisma.scheduledSms.findMany({ where: { status: 'pending' } }); let rescheduledCount = 0; for (const job of pendingJobs) { const sendAt = new Date(job.send_at); // Ensure it's a Date object // Check if the job's time is still in the future if (sendAt > new Date()) { try { const cronTime = dateToCron(sendAt); // Recalculate just in case const task = cron.schedule(cronTime, () => { sendSms(job.id, { to: job.to_number, message: job.message_text }); activeCronTasks.delete(job.id); }, { scheduled: true, timezone: ""Etc/UTC"" }); activeCronTasks.set(job.id, task); console.log(`[${job.id}] Rescheduled pending job for ${sendAt.toISOString()}`); rescheduledCount++; } catch (error) { console.error(`[${job.id}] Failed to reschedule job:`, error); // Optionally update DB status to 'failed_scheduling' await prisma.scheduledSms.update({ where: { id: job.id }, data: { status: 'failed_scheduling', error_message: `Reschedule failed: ${error.message}` } }); } } else { // The scheduled time has passed while the server was down console.warn(`[${job.id}] Pending job's scheduled time ${sendAt.toISOString()} has passed. Sending immediately or marking as missed.`); // Option 1: Send immediately // sendSms(job.id, { to: job.to_number, message: job.message_text }); // Option 2: Mark as 'missed' or 'failed' await prisma.scheduledSms.update({ where: { id: job.id }, data: { status: 'failed', error_message: 'Scheduled time missed during downtime' } }); } } console.log(`Rescheduled ${rescheduledCount} pending jobs.`); } // --------------------------------------------------------- module.exports = { scheduleSms, getJobStatus, cancelJob, loadAndReschedulePendingJobs // Export the new function }; // --- Call loadAndReschedulePendingJobs when the application starts --- // --- This should be called from server.js after connections are ready ---
- Prisma Client: Import and instantiate
PrismaClient
. - Database Operations: Replace
scheduledJobs.set
,scheduledJobs.get
,scheduledJobs.delete
withprisma.scheduledSms.create
,prisma.scheduledSms.findUnique
,prisma.scheduledSms.update
. - Active Task Management: Since
node-cron
tasks run in memory, you still need a way to track active tasks (e.g.,activeCronTasks
map) so they can be cancelled (task.stop()
). This map needs to be repopulated on server start by rescheduling pending jobs from the database. - Job Loading on Startup: Implement
loadAndReschedulePendingJobs
to query the database for'pending'
jobs when the application starts. Reschedule jobs whosesend_at
time is still in the future. Decide how to handle jobs whose time has already passed (send immediately, mark as failed/missed). Call this function from your main server startup logic (src/server.js
).
- Prisma Client: Import and instantiate