This guide provides a step-by-step walkthrough for building an application to send basic SMS marketing campaigns using Node.js, Express, and the Vonage Messages API. We'll cover project setup, core sending logic, API creation, database integration, error handling, security considerations, and deployment strategies.
By the end of this tutorial, you will have a functional backend service capable of accepting a list of recipients and a message, then sending that message via SMS to each recipient using Vonage.
Important Note: This guide builds a foundational service. Key production features like robust API authentication, comprehensive opt-out handling via webhooks, and delivery receipt processing are discussed but not fully implemented in the provided code. These are critical additions for a true production deployment.
Project Overview and Goals
Goal: To create a simple, robust backend service that can programmatically send SMS messages to a list of phone numbers for marketing campaign purposes.
Problem Solved: Manually sending SMS messages to multiple recipients is inefficient and error-prone. This service automates the process, enabling scalable SMS outreach via an API.
Technologies Used:
- Node.js: A JavaScript runtime built on Chrome's V8 engine, ideal for building fast, scalable network applications.
- Express: A minimal and flexible Node.js web application framework providing a robust set of features for web and mobile applications.
- Vonage Messages API: A powerful API for sending and receiving messages across various channels, including SMS. We'll use it for its reliability and features. We specifically use the Application ID and Private Key authentication method for enhanced security.
- Prisma: A modern database toolkit for Node.js and TypeScript, simplifying database access with an intuitive ORM. We'll use it with SQLite for local development simplicity, but it easily adapts to PostgreSQL or MySQL for production.
- dotenv: A zero-dependency module that loads environment variables from a
.env
file intoprocess.env
.
System Architecture:
+-----------------+ +---------------------+ +----------------+ +-----------------+
| API Client |----->| Node.js/Express |----->| Prisma Client |----->| Database |
| (e.g., Postman) | | API Layer | | (Data Access) | | (e.g., SQLite) |
+-----------------+ +----------+----------+ +----------------+ +-----------------+
|
| (Vonage SDK)
v
+-----------------+
| Vonage Messages |
| API |
+-----------------+
|
v
+-----------------+
| SMS Recipient |
+-----------------+
Prerequisites:
- Node.js and npm (or yarn): Installed on your development machine. Download Node.js
- Vonage API Account: Sign up for free at Vonage. You'll receive some initial free credit.
- Vonage Application: You'll need to create a Vonage application and generate a private key.
- Vonage Phone Number: You need a Vonage virtual number capable of sending SMS. You can purchase one from the Vonage dashboard.
- (Optional) ngrok: Useful for testing webhook implementations (like opt-out replies or delivery receipts described in Step 8) locally, though not required for the basic sending functionality built here. Download ngrok
- (Optional) Postman or curl: For testing the API endpoints.
1. Setting Up the Project
Let's initialize the project, install dependencies, and set up the basic structure.
1. Create Project Directory: Open your terminal and create a new directory for your project.
mkdir vonage-sms-campaigner
cd vonage-sms-campaigner
2. Initialize Node.js Project:
This creates a package.json
file.
npm init -y
3. Install Dependencies:
express
: Web framework.@vonage/server-sdk
: The official Vonage Node.js SDK (we'll use the Messages API part).prisma
: The Prisma CLI for migrations and database management.@prisma/client
: The Prisma database client.dotenv
: For managing environment variables.
npm install express @vonage/server-sdk @prisma/client dotenv
npm install prisma --save-dev
4. Initialize Prisma: Set up Prisma with SQLite for simplicity in development.
npx prisma init --datasource-provider sqlite
This creates:
- A
prisma
directory with aschema.prisma
file. - A
.env
file (if it doesn't exist) with aDATABASE_URL
variable.
5. Define Project Structure: Create the following directories and files:
vonage-sms-campaigner/
├── prisma/
│ ├── schema.prisma
│ └── dev.db # (Will be created by Prisma)
├── src/
│ ├── controllers/
│ │ └── campaignController.js
│ ├── services/
│ │ ├── vonageService.js
│ │ └── campaignService.js
│ ├── middleware/ # (Add this for Step 7)
│ │ └── rateLimiter.js
│ ├── routes/
│ │ └── campaignRoutes.js
│ └── server.js
├── .env # For environment variables (DO NOT COMMIT)
├── .gitignore
└── package.json
6. Configure .gitignore
:
Ensure sensitive files and unnecessary directories aren't committed to version control. Create or edit .gitignore
:
# Dependencies
node_modules/
# Prisma
prisma/dev.db
prisma/dev.db-journal
# Environment Variables
.env*
# Build Outputs (if applicable)
dist/
build/
# OS files
.DS_Store
Thumbs.db
# Vonage Private Key
private.key
*.key
7. Set up Basic Express Server (src/server.js
):
// src/server.js
require('dotenv').config(); // Load .env variables first
const express = require('express');
const campaignRoutes = require('./routes/campaignRoutes');
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 Root Route
app.get('/', (req, res) => {
res.send('Vonage SMS Campaigner API is running!');
});
// API Routes
app.use('/api/campaigns', campaignRoutes);
// Simple Error Handling Middleware (Will be enhanced in Step 5)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send({ error: 'Something went wrong!' });
});
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
8. Configure Environment Variables (.env
):
Create the .env
file in the project root. We'll add Vonage credentials later. For now, set the database URL (Prisma already added this) and optionally the port. Replace the placeholder values after completing Step 4.
# .env
# Database Configuration (Prisma default)
DATABASE_URL="file:./prisma/dev.db"
# Server Port
PORT=3000
# Vonage Credentials (Add these later in Step 4)
VONAGE_APPLICATION_ID=YOUR_VONAGE_APP_ID_FROM_DASHBOARD # Replace with actual ID
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root where you save the key
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER_E164 # Replace with actual Vonage number (e.g., +12015550123)
Why .env
? Storing configuration like API keys and database URLs in environment variables keeps sensitive data out of your codebase and makes it easy to configure different deployment environments.
2. Implementing Core Functionality
We need services to interact with Vonage and manage the campaign sending logic.
1. Vonage Service (src/services/vonageService.js
):
This service initializes the Vonage SDK and provides a function to send a single SMS.
// src/services/vonageService.js
const { Vonage } = require('@vonage/server-sdk');
const path = require('path');
const fs = require('fs'); // Needed to check if key file exists
// Resolve path from the current working directory where Node is started
const privateKeyPath = process.env.VONAGE_PRIVATE_KEY_PATH
? path.resolve(process.cwd(), process.env.VONAGE_PRIVATE_KEY_PATH)
: null;
if (!privateKeyPath || !fs.existsSync(privateKeyPath)) {
console.error(`Error: Vonage private key file not found at path specified by VONAGE_PRIVATE_KEY_PATH: ${process.env.VONAGE_PRIVATE_KEY_PATH}`);
// Optionally exit or handle appropriately
// process.exit(1);
}
const vonage = new Vonage({
applicationId: process.env.VONAGE_APPLICATION_ID,
privateKey: privateKeyPath, // Use the resolved absolute path
});
const fromNumber = process.env.VONAGE_NUMBER;
/**
* Sends a single SMS message using the Vonage Messages API.
* @param {string} to - The recipient's phone number (E.164 format recommended).
* @param {string} text - The message content.
* @returns {Promise<object>} - Promise resolving with the Vonage API response.
* @throws {Error} - Throws an error if sending fails.
*/
async function sendSms(to, text) {
if (!to || !text) {
throw new Error('Recipient phone number and message text are required.');
}
if (!process.env.VONAGE_APPLICATION_ID || !privateKeyPath || !fromNumber) {
throw new Error('Vonage credentials (Application ID, Private Key Path, Number) are missing or invalid in .env');
}
console.log(`Attempting to send SMS from ${fromNumber} to ${to}`);
try {
// Use standard double quotes for JSON string values
const resp = await vonage.messages.send({
message_type: ""text"",
text: text,
to: to,
from: fromNumber,
channel: ""sms""
});
console.log(`Message sent successfully to ${to}: ${resp.message_uuid}`);
return resp; // Contains message_uuid
} catch (err) {
// Log more detailed Vonage error if available
const errorMessage = err.response?.data?.title || err.response?.data?.detail || err.message || 'Unknown Vonage error';
const errorStatus = err.response?.status || 'N/A';
console.error(`Error sending SMS to ${to}: Status ${errorStatus} - ${errorMessage}`);
// Rethrow a more informative error
throw new Error(`Failed to send SMS to ${to}. Vonage API Error: ${errorMessage} (Status: ${errorStatus})`);
}
}
module.exports = { sendSms };
Why Application ID/Private Key? This authentication method is generally more secure than API Key/Secret for server-to-server communication, as the private key never leaves your server. The SDK handles the JWT generation needed for authentication.
2. Campaign Service (src/services/campaignService.js
):
This service orchestrates the sending process, fetching recipients and iterating through them to send messages via the vonageService
.
// src/services/campaignService.js
const { PrismaClient } = require('@prisma/client');
const { sendSms } = require('./vonageService');
const prisma = new PrismaClient();
/**
* Sends an SMS campaign to a list of recipients.
* @param {string[]} recipientNumbers - Array of phone numbers (E.164 format recommended).
* @param {string} message - The message text to send.
* @returns {Promise<object>} - Promise resolving with results (successes, failures).
*/
async function sendCampaign(recipientNumbers, message) {
if (!Array.isArray(recipientNumbers) || recipientNumbers.length === 0) {
throw new Error('Recipient numbers must be a non-empty array.');
}
if (!message) {
throw new Error('Message text cannot be empty.');
}
const results = {
successes: [],
failures: [],
};
// Sequentially send messages to avoid overwhelming the API rate limits initially
// For higher volume, consider batching or parallel requests with delays & proper rate limiting
for (const number of recipientNumbers) {
try {
// Basic E.164 format check (can be improved with regex)
if (!/^\+[1-9]\d{1,14}$/.test(number)) {
throw new Error(`Invalid phone number format: ${number}. Must be E.164 (e.g., +14155552671).`);
}
const response = await sendSms(number, message);
results.successes.push({ number: number, message_uuid: response.message_uuid });
// Optional: Add a small delay between sends if hitting rate limits
// await new Promise(resolve => setTimeout(resolve, 200)); // 200ms delay
} catch (error) {
console.error(`Failed processing number ${number}: ${error.message}`);
results.failures.push({ number: number, error: error.message });
}
}
console.log(`Campaign finished. Success: ${results.successes.length}, Failures: ${results.failures.length}`);
return results;
}
// --- Example of fetching recipients from DB (Requires Schema Update - See Step 6) ---
/**
* Sends an SMS campaign to recipients belonging to a specific group.
* @param {number} recipientGroupId - The ID of the RecipientGroup.
* @param {string} message - The message text to send.
* @returns {Promise<object>} - Promise resolving with results (successes, failures).
*/
async function sendCampaignToGroup(recipientGroupId, message) {
const groupWithRecipients = await prisma.recipientGroup.findUnique({
where: { id: recipientGroupId },
include: {
recipients: { // Only include recipients who haven't opted out
where: { isOptedOut: { not: true } } // Requires 'isOptedOut' field in schema (Step 8)
}
},
});
if (!groupWithRecipients) {
throw new Error(`Recipient group with ID ${recipientGroupId} not found.`);
}
const recipientNumbers = groupWithRecipients.recipients.map(r => r.phoneNumber);
if (recipientNumbers.length === 0) {
console.warn(`Recipient group ${recipientGroupId} has no active recipients.`);
return { successes: [], failures: [] };
}
return sendCampaign(recipientNumbers, message);
}
module.exports = { sendCampaign, sendCampaignToGroup }; // Export both functions
Design Choice: Sending messages sequentially is simpler to start with. For large campaigns, you'd implement parallel sending with rate limiting awareness (e.g., using libraries like p-limit
or async.queue
) and potentially handle Vonage's asynchronous responses via status webhooks for better tracking (See Step 8).
3. Building a Complete API Layer
We need an endpoint to trigger the campaign sending process.
1. Campaign Controller (src/controllers/campaignController.js
):
Handles incoming API requests, validates input, calls the appropriate service, and sends back the response.
// src/controllers/campaignController.js
const { sendCampaign, sendCampaignToGroup } = require('../services/campaignService');
/**
* POST /api/campaigns/send
* Sends a campaign to a provided list of numbers.
* Request body example: { "recipientNumbers": ["+15551234567", "+447700900123"], "message": "Hello!" }
*/
async function handleSendCampaign(req, res, next) {
const { recipientNumbers, message } = req.body;
// Basic Validation
if (!Array.isArray(recipientNumbers) || recipientNumbers.length === 0 || typeof message !== 'string' || message.trim() === '') {
return res.status(400).json({
error: 'Invalid input. Requires "recipientNumbers" (non-empty array of strings) and "message" (non-empty string).',
});
}
// Further validation (e.g., check number format within the service or here)
try {
const results = await sendCampaign(recipientNumbers, message);
res.status(200).json({
message: 'Campaign processing completed.', // Changed from "initiated" as it's sequential for now
results: results,
});
} catch (error) {
console.error('Error in handleSendCampaign:', error);
// Pass to the centralized error handler
next(error);
}
}
/**
* POST /api/campaigns/send-group
* Sends a campaign to recipients of a specific group ID.
* Request body example: { "recipientGroupId": 1, "message": "Group message!" }
*/
async function handleSendCampaignToGroup(req, res, next) {
const { recipientGroupId, message } = req.body;
// Basic Validation
const groupId = parseInt(recipientGroupId, 10);
if (isNaN(groupId) || typeof message !== 'string' || message.trim() === '') {
return res.status(400).json({
error: 'Invalid input. Requires "recipientGroupId" (number) and "message" (non-empty string).',
});
}
try {
const results = await sendCampaignToGroup(groupId, message);
res.status(200).json({
message: `Campaign processing completed for group ${groupId}.`, // Changed from "initiated"
results: results,
});
} catch (error) {
console.error('Error in handleSendCampaignToGroup:', error);
// Handle specific errors like "group not found" differently if needed
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
// Pass other errors to the centralized handler
next(error);
}
}
module.exports = { handleSendCampaign, handleSendCampaignToGroup };
2. Campaign Routes (src/routes/campaignRoutes.js
):
Defines the API endpoints and maps them to controller functions. Note: These endpoints currently lack authentication (See Step 7).
// src/routes/campaignRoutes.js
const express = require('express');
const { handleSendCampaign, handleSendCampaignToGroup } = require('../controllers/campaignController');
// Rate limiting middleware will be introduced and implemented in Step 7
const rateLimiter = require('../middleware/rateLimiter');
const router = express.Router();
// WARNING: These endpoints are currently PUBLIC. Add authentication middleware for production.
// Apply rate limiting (introduced in Step 7) to campaign sending endpoints
router.post('/send', rateLimiter, handleSendCampaign);
router.post('/send-group', rateLimiter, handleSendCampaignToGroup);
// Optional: Endpoint to check basic Vonage configuration status
router.get('/status', (req, res) => {
// Basic check: Ensure Vonage credentials seem present in environment
if (process.env.VONAGE_APPLICATION_ID && process.env.VONAGE_PRIVATE_KEY_PATH && process.env.VONAGE_NUMBER) {
// Further check could involve ensuring the private key file exists
res.status(200).json({ status: 'Vonage credentials seem loaded from environment variables.'});
} else {
res.status(500).json({ status: 'Error: One or more required Vonage credentials missing in environment variables.' });
}
});
module.exports = router;
3. Testing the Endpoint (Example with curl
):
(Before running, ensure you've completed Step 4 for Vonage setup and Step 6 for Database setup if using /send-group
)
Start your server: node src/server.js
-
Test
/send
endpoint:curl -X POST http://localhost:3000/api/campaigns/send \ -H "Content-Type: application/json" \ -d '{ "recipientNumbers": ["+15551234567", "+447700900123"], "message": "Hello from our Vonage Campaigner!" }' # Replace numbers with your actual test numbers (whitelisted on trial accounts)
Expected Response (Success):
{ "message": "Campaign processing completed.", "results": { "successes": [ { "number": "+15551234567", "message_uuid": "..." }, { "number": "+447700900123", "message_uuid": "..." } ], "failures": [] } }
-
Test
/send-group
endpoint (after DB setup in Step 6):curl -X POST http://localhost:3000/api/campaigns/send-group \ -H "Content-Type: application/json" \ -d '{ "recipientGroupId": 1, "message": "Special offer for our valued group members!" }'
Expected Response (Success):
{ "message": "Campaign processing completed for group 1.", "results": { "successes": [ /* ... list of successful sends ... */ ], "failures": [] } }
4. Integrating with Vonage
This involves setting up your Vonage account, creating an application, and configuring your .env
file.
1. Sign Up/Log In to Vonage: Go to the Vonage API Dashboard.
2. Create a Vonage Application:
- Navigate to ""Applications"" -> ""Create a new application"".
- Give it a name (e.g., ""Node SMS Campaigner"").
- Click ""Generate public and private key"". Immediately save the
private.key
file that downloads. Place this file in the root directory of your project (or the location specified byVONAGE_PRIVATE_KEY_PATH
in your.env
). - Enable ""Messages"" capability.
- For the Inbound URL and Status URL, you need publicly accessible endpoints if you want to handle replies (opt-outs) or delivery receipts (See Step 8).
- For local testing (if implementing webhooks):
- Run
ngrok http 3000
(assuming your server runs on port 3000). - Copy the HTTPS forwarding URL provided by ngrok (e.g.,
https://random-string.ngrok.io
). - Enter
YOUR_NGROK_URL/webhooks/inbound
for Inbound URL (e.g.,https://random-string.ngrok.io/webhooks/inbound
). - Enter
YOUR_NGROK_URL/webhooks/status
for Status URL (e.g.,https://random-string.ngrok.io/webhooks/status
). - (Note: The webhook handlers themselves are not implemented in this guide's code).
- Run
- For production: Use your deployed application's webhook URLs.
- For local testing (if implementing webhooks):
- Click ""Generate new application"".
3. Note Your Application ID: On the application details page, copy the Application ID.
4. Link a Vonage Number:
- Go to ""Numbers"" -> ""Your numbers"".
- If you don't have one, click ""Buy numbers"", find one with SMS capability in your desired country, and purchase it.
- Go back to your Application details (""Applications"" -> Your App).
- Under ""Link numbers"", find your virtual number and click ""Link"".
5. Configure .env
:
Open your .env
file and fill in the Vonage details with the values you obtained:
# .env (Update these lines with your actual values)
# ... other variables ...
VONAGE_APPLICATION_ID=PASTE_YOUR_APPLICATION_ID_HERE # Replace with the ID from Vonage Dashboard
VONAGE_PRIVATE_KEY_PATH=./private.key # Ensure this path is correct relative to project root
VONAGE_NUMBER=PASTE_YOUR_VONAGE_NUMBER_HERE # Replace with your linked Vonage number in E.164 format (e.g., +14155552671)
VONAGE_APPLICATION_ID
: The ID copied from the Vonage dashboard.VONAGE_PRIVATE_KEY_PATH
: The path relative to your project root where you saved theprivate.key
file../private.key
assumes it's directly in the root. The code (vonageService.js
) resolves this relative to the current working directory.VONAGE_NUMBER
: The Vonage virtual number (must be in E.164 format, e.g.,+14155552671
) you linked to the application.
Security: NEVER commit your private.key
file or your .env
file containing secrets to Git. Ensure .gitignore
includes them (add *.key
and .env*
).
5. Implementing Error Handling and Logging
Robust error handling and logging are crucial.
1. Consistent Error Handling:
- Services (
vonageService
,campaignService
) usetry...catch
and throw errors, sometimes re-throwing more specific ones. - The controller (
campaignController.js
) catches errors from services and either returns specific client errors (400, 404) or passes server errors (next(error)
) to the central Express error handler. - Replace the basic error middleware in
server.js
with a more structured one.
Example: Enhance server.js
Error Handler:
// src/server.js (Replace the simple error handler with this)
// Remove or comment out the previous simple error handler:
// app.use((err, req, res, next) => {
// console.error(err.stack);
// res.status(500).send({ error: 'Something went wrong!' });
// });
// More structured error handler (Place this AFTER your routes)
app.use((err, req, res, next) => {
// Log the error internally (replace console.error with a proper logger like Winston/Pino in production)
console.error(`[ERROR] ${req.method} ${req.path} - ${err.message}`);
// Log stack trace only in development or if needed for detailed debugging
if (process.env.NODE_ENV !== 'production') {
console.error(err.stack);
}
// Determine status code - check for custom status or default to 500
// Use err.status for compatibility with some error libraries, fallback to statusCode
const statusCode = err.status || err.statusCode || 500;
// Send structured error response to client - avoid leaking sensitive details
res.status(statusCode).json({
status: 'error',
// Only include specific error message in non-production environments for debugging
// In production, use a generic message unless it's a specific client error (4xx) you want to expose
message: (statusCode < 500 || process.env.NODE_ENV !== 'production') ? err.message : 'An internal server error occurred.'_
});
});
2. Logging:
- Currently using
console.log
andconsole.error
. This is insufficient for production monitoring and debugging. - Recommendation: Integrate a structured logging library like
Pino
(very performant) orWinston
.- Configure log levels (info_ warn_ error_ debug).
- Log to standard output (for containerized environments) or files. Consider sending logs to a centralized log management service (e.g._ Datadog_ Logtail_ Sentry_ ELK Stack).
- Include timestamps_ request IDs (using middleware like
express-request-id
)_ and relevant context (e.g._ user ID if authenticated_ campaign ID). - Log key events: Application start_ API requests (incoming/outgoing)_ campaign start/finish_ individual SMS send attempts (success/failure with IDs)_ errors encountered_ database operations.
3. Retry Mechanisms:
-
Network issues or temporary Vonage API rate limits can cause transient failures.
-
Simple Retry (Conceptual): Wrap the
vonageService.sendSms
call within thecampaignService
loop with delays (exponential backoff is recommended).// Inside campaignService.js - Conceptual Retry Logic (Not fully implemented here) async function sendSmsWithRetry(number_ message_ maxRetries = 2) { let attempt = 0; while (attempt <= maxRetries) { try { return await sendSms(number_ message); // Attempt sending } catch (error) { attempt++; // Check if the error is potentially retryable (e.g._ network error_ 429 Too Many Requests) const isRetryable = error.message.includes('429') || error.message.includes('network'); // Basic check if (!isRetryable || attempt > maxRetries) { console.error(`Final attempt failed for ${number} after ${attempt} tries or error not retryable.`); throw error; // Rethrow original error } // Exponential backoff: wait 1s, 2s, 4s... const delay = Math.pow(2, attempt - 1) * 1000 * (Math.random() + 0.5); // Add jitter console.warn(`Attempt ${attempt} failed for ${number}. Retrying in ${delay.toFixed(0)}ms... Error: ${error.message}`); await new Promise(resolve => setTimeout(resolve, delay)); } } } // You would then modify sendCampaign to call sendSmsWithRetry instead of sendSms directly.
-
Robust Retries: Use libraries like
async-retry
or leverage a background job queue system (Step 9) which often have built-in retry capabilities. For delivery confirmation, rely on Delivery Receipts via webhooks (Step 8).
6. Creating a Database Schema and Data Layer
We'll use Prisma to define a schema for recipients and groups.
1. Define Prisma Schema (prisma/schema.prisma
):
Update the schema file to include models for Recipient
and RecipientGroup
. Add an isOptedOut
flag for compliance (See Step 8).
// prisma/schema.prisma
generator client {
provider = ""prisma-client-js""
}
datasource db {
provider = ""sqlite"" // Or ""postgresql"", ""mysql""
url = env(""DATABASE_URL"")
}
model Recipient {
id Int @id @default(autoincrement())
phoneNumber String @unique // E.164 format REQUIRED
firstName String?
lastName String?
isOptedOut Boolean @default(false) // For handling STOP replies
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relation to RecipientGroup (a recipient can belong to one group)
recipientGroup RecipientGroup? @relation(fields: [recipientGroupId], references: [id])
recipientGroupId Int?
@@index([recipientGroupId]) // Index for faster group lookups
}
model RecipientGroup {
id Int @id @default(autoincrement())
name String @unique // e.g., ""Newsletter Subscribers"", ""VIP Customers""
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relation to multiple recipients
recipients Recipient[]
}
Recipient
: Stores individual phone numbers (enforce E.164 format), optional names, and the crucialisOptedOut
flag.RecipientGroup
: Allows grouping recipients.
2. Apply Migrations: Generate and apply the SQL migration to create/update the database tables.
# Create the migration files based on schema changes
# Prisma will prompt you for a name, e.g., add_recipient_models_and_optout
npx prisma migrate dev
# This command will also apply the migration to your dev database (dev.db)
Prisma creates an SQL migration file in prisma/migrations/
and updates your dev.db
.
3. (Optional) Seed the Database: Create sample data for testing.
-
Create
prisma/seed.js
:// prisma/seed.js const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); async function main() { console.log('Start seeding ...'); // Create recipient groups const group1 = await prisma.recipientGroup.upsert({ where: { name: 'Early Adopters' }, update: {}, create: { name: 'Early Adopters' }, }); const group2 = await prisma.recipientGroup.upsert({ where: { name: 'General Newsletter' }, update: {}, create: { name: 'General Newsletter' }, }); console.log(`Created/found groups: ${group1.name} (ID: ${group1.id}), ${group2.name} (ID: ${group2.id})`); // Create recipients, linking some to groups await prisma.recipient.upsert({ where: { phoneNumber: '+15550001111' }, // Use test numbers update: {}, create: { phoneNumber: '+15550001111', firstName: 'Alice', recipientGroupId: group1.id, }, }); await prisma.recipient.upsert({ where: { phoneNumber: '+447700900222' }, // Use test numbers update: {}, create: { phoneNumber: '+447700900222', firstName: 'Bob', lastName: 'Smith', recipientGroupId: group1.id, }, }); await prisma.recipient.upsert({ where: { phoneNumber: '+15559998888' }, // Use test numbers update: { isOptedOut: true }, // Example of an opted-out user create: { phoneNumber: '+15559998888', firstName: 'Charlie', isOptedOut: true, // Ensure they start opted out recipientGroupId: group2.id, }, }); await prisma.recipient.upsert({ where: { phoneNumber: '+15557776666' }, // Use test numbers update: {}, create: { phoneNumber: '+15557776666', firstName: 'Diana', // No group initially }, }); console.log('Seeding finished.'); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); });
-
Add the seed script command to
package.json
:// package.json { // ... other package.json content ... ""prisma"": { ""seed"": ""node prisma/seed.js"" }, ""scripts"": { ""start"": ""node src/server.js"", ""dev"": ""nodemon src/server.js"", // If using nodemon ""migrate:dev"": ""npx prisma migrate dev"", ""db:seed"": ""npx prisma db seed"" // Add this line // ... other scripts ... } // ... rest of package.json ... }
-
Run the seed script:
npx prisma db seed
This executes the
prisma/seed.js
file.