Build Production-Ready SMS Campaigns with Node.js, Express, and Plivo
This guide provides a step-by-step walkthrough for building a robust SMS marketing campaign application using Node.js, the Express framework, and the Plivo communication platform API. You'll learn how to set up the project, send bulk SMS messages, handle replies and opt-outs, manage subscribers, and deploy your application securely and reliably.
We'll build a system capable of managing subscriber lists, creating SMS campaigns, sending messages asynchronously via a job queue, handling incoming messages (like replies or STOP requests), and logging message statuses. This approach solves the challenge of sending personalized or bulk SMS messages reliably and scalably, directly from your application backend.
Technologies Used:
- Node.js: JavaScript runtime environment.
- Express.js: Minimalist web framework for Node.js, used for building the API layer.
- Plivo Node.js SDK: Simplifies interaction with the Plivo SMS API.
- PostgreSQL: Relational database for storing subscriber and campaign data.
- Prisma: Modern ORM for Node.js and TypeScript, simplifying database interactions.
- BullMQ: Robust job queue system based on Redis, for handling asynchronous SMS sending.
- Redis: In-memory data structure store, used by BullMQ.
- dotenv: Module for loading environment variables from a
.env
file. - ngrok (for development): Tool to expose local servers to the internet for webhook testing.
System Architecture:
+-----------------+ +-----------------+ +----------------+ +----------------+
| User/Admin | ---> | Express API | ---> | BullMQ | ---> | Plivo SMS API |
| (API Client) | | (Node.js) | | (Redis Job Q) | | |
+-----------------+ +-------+---------+ +-------+--------+ +-------+--------+
| | | | | |
| Prisma ORM |<----->| PostgreSQL DB | | Plivo Webhooks
| | | (Subscribers, | | (Incoming SMS,
+-------+---------+ | Campaigns) | <--------------+ Status Updates)
+----------------+
Prerequisites:
- Node.js and npm (or yarn) installed.
- Access to a PostgreSQL database.
- A Redis instance (local or cloud-based).
- A Plivo account.
- Basic understanding of Node.js_ Express_ REST APIs_ and databases.
ngrok
installed for local development webhook testing.- Production Webhook URL: For production deployment_ you will need a stable_ publicly accessible URL for Plivo webhooks. This is typically provided by your hosting platform (e.g._ Heroku app URL_ AWS Load Balancer URL) or a dedicated tunneling service.
ngrok
's free tier URLs are temporary and not suitable for production.
By the end of this guide_ you will have a functional backend application capable of managing and executing SMS campaigns_ ready for further enhancement and deployment.
1. Project Setup and Configuration
Let's initialize the Node.js project and install necessary dependencies.
-
Create Project Directory:
mkdir sms-campaign-app cd sms-campaign-app
-
Initialize Node.js Project:
npm init -y
-
Install Dependencies:
npm install express plivo @prisma/client dotenv bullmq ioredis express-validator winston express-rate-limit
-
Install Development Dependencies:
npm install --save-dev prisma nodemon jest supertest
prisma
: For Prisma CLI commands (migrations_ generation).nodemon
: Automatically restarts the server during development.jest
_supertest
: For testing.
-
Initialize Prisma:
npx prisma init --datasource-provider postgresql
This creates a
prisma
directory with aschema.prisma
file and a.env
file. -
Configure Environment Variables (
.env
): Open the.env
file created by Prisma and add the following variables. Remove any unnecessary quotes.# .env # Database # Example: postgresql://user:password@host:port/database?schema=public # Note: Quotes might be needed if your password contains special characters. DATABASE_URL=""postgresql://your_db_user:your_db_password@localhost:5432/sms_campaigns"" # Plivo Credentials PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN PLIVO_SENDER_ID=YOUR_PLIVO_PHONE_NUMBER_OR_SENDER_ID # Plivo phone number in E.164 format or Alphanumeric Sender ID # Redis Connection (for BullMQ) # Example for local Redis: redis://localhost:6379 # Example for cloud Redis: redis://:password@host:port REDIS_URL=""redis://localhost:6379"" # Application Settings PORT=3000 API_KEY=YOUR_SECRET_API_KEY # Simple API key for securing endpoints BASE_URL=http://localhost:3000 # Base URL for generating webhook URLs (update for development/production)
DATABASE_URL
: Connection string for your PostgreSQL database. Replace placeholders with your actual credentials. Use quotes if your password or user contains special characters.PLIVO_AUTH_ID
_PLIVO_AUTH_TOKEN
: Your Plivo API credentials.PLIVO_SENDER_ID
: The Plivo phone number (in E.164 format_ e.g._+14155551212
) or Alphanumeric Sender ID you'll use to send messages. Numbers are required for US/Canada.REDIS_URL
: Connection string for your Redis instance.PORT
: Port your Express application will run on.API_KEY
: A simple secret key for basic API authentication (use more robust methods like JWT for production).BASE_URL
: The public-facing base URL of your application. Critical for webhook configuration. Use yourngrok
URL during development_ and your production URL otherwise.
-
Add
nodemon
script topackage.json
: Update thescripts
section in yourpackage.json
:// package.json { ""scripts"": { ""start"": ""node src/server.js""_ ""dev"": ""nodemon src/server.js""_ ""test"": ""jest"" // Add other scripts like prisma migrate_ etc. } }
-
Create Project Structure: Organize your project for maintainability:
sms-campaign-app/ ├── prisma/ │ ├── schema.prisma │ └── migrations/ ├── src/ │ ├── config/ # Configuration files (db_ plivo_ redis) │ ├── controllers/ # Request handlers │ ├── jobs/ # BullMQ job definitions │ ├── middleware/ # Express middleware (auth_ validation_ error handling) │ ├── models/ # (Optional) Business logic models if needed beyond Prisma │ ├── routes/ # API route definitions │ ├── services/ # Business logic (interacting with DB_ Plivo_ etc.) │ ├── utils/ # Utility functions (logging_ etc.) │ ├── workers/ # BullMQ worker processes │ ├── app.js # Express app configuration │ └── server.js # Server entry point ├── tests/ # Unit and integration tests ├── .env ├── .gitignore └── package.json
Create these directories.
-
Basic Server Setup (
src/server.js
):// src/server.js require('dotenv').config(); const app = require('./app'); const logger = require('./utils/logger'); // We'll create this later const PORT = process.env.PORT || 3000; app.listen(PORT_ () => { logger.info(`Server running on port ${PORT}`); logger.info(`API base URL: ${process.env.BASE_URL}`); // Note: BullMQ workers should typically run in a separate process. });
-
Basic App Setup (
src/app.js
):// src/app.js const express = require('express'); const rateLimit = require('express-rate-limit'); // Routes and middleware will be imported and used as defined in later sections const app = express(); // Middleware (Order matters!) // Apply rate limiting early const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per windowMs standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers message: 'Too many requests from this IP, please try again after 15 minutes', }); app.use(limiter); // We will mount webhook routes *before* express.json() if raw body is needed (See Section 6.4) // Parse JSON bodies for most routes app.use(express.json()); // Parse URL-encoded bodies app.use(express.urlencoded({ extended: true })); // Request logging, Authentication, API routes, and Error Handling will be added later. // Health check endpoint app.get('/health', (req, res) => { res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() }); }); // Routes and error handler will be mounted here in subsequent sections. module.exports = app;
This initial setup provides a solid foundation with essential dependencies, environment configuration, and a basic project structure.
2. Integrating with Plivo
Now, let's configure Plivo and set up the necessary components to interact with its API.
-
Sign Up/Log In to Plivo:
- Go to plivo.com and sign up for an account or log in.
- Trial Account Limitation: If using a trial account, you can only send SMS to phone numbers verified in your Plivo console (Phone Numbers > Sandbox Numbers). You'll also have a
""[Plivo Trial]""
prefix added to messages. Purchase credits to remove these limitations.
-
Get API Credentials:
- Navigate to your Plivo Console Dashboard.
- Locate the Auth ID and Auth Token on the right side of the dashboard.
- Copy these values and paste them into your
.env
file forPLIVO_AUTH_ID
andPLIVO_AUTH_TOKEN
. Keep these secret! Do not commit them to version control.
-
Get a Plivo Phone Number:
- You need an SMS-enabled Plivo phone number to send and receive messages (required for US/Canada).
- Go to Phone Numbers > Buy Numbers in the console.
- Search for numbers with SMS capability in your desired country.
- Purchase a number.
- Copy the full number in E.164 format (e.g.,
+14155551212
) and add it to your.env
file asPLIVO_SENDER_ID
. - Alternatively, for supported countries outside North America, you might use an Alphanumeric Sender ID configured in your Plivo account under Messaging > Sender IDs.
-
Configure Plivo Application for Webhooks: Plivo uses webhooks to notify your application about incoming messages and delivery status updates. You need to create a Plivo Application and link your Plivo number to it.
- Go to Messaging > Applications > XML.
- Click ""Add New Application"".
- Application Name: Give it a descriptive name (e.g., ""Node Campaign App"").
- Message URL: This is where Plivo sends incoming SMS data. Enter the publicly accessible URL for your incoming message handler. During development, use
ngrok
.- Start
ngrok
:ngrok http 3000
(or yourPORT
). - Copy the HTTPS forwarding URL provided by
ngrok
(e.g.,https://<unique_id>.ngrok.io
). - Your Message URL will be:
https://<unique_id>.ngrok.io/webhooks/plivo/incoming
(we'll create this endpoint later). - Crucially update your
.env
BASE_URL
to this ngrok URL during development.
- Start
- Method: Set to
POST
. - Delivery Report URL (Optional but Recommended): Where Plivo sends message status updates (sent, failed, delivered). Set this similarly:
https://<unique_id>.ngrok.io/webhooks/plivo/status
. Set method toPOST
. - Click ""Create Application"".
- Link Number to Application: Go back to Phone Numbers > Your Numbers. Click on the number you purchased. In the ""Application Type"" section, select ""XML Application"". From the ""Plivo Application"" dropdown, choose the application you just created (""Node Campaign App""). Click ""Update Number"".
-
Create Plivo Service (
src/services/plivoService.js
): This service encapsulates interaction with the Plivo SDK.// src/services/plivoService.js const plivo = require('plivo'); const logger = require('../utils/logger'); const client = new plivo.Client(process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN); const sendSms = async (to, text, callbackUrl = null) => { const senderId = process.env.PLIVO_SENDER_ID; if (!senderId) { logger.error('PLIVO_SENDER_ID is not configured in .env'); throw new Error('Sender ID not configured'); } // Basic validation if (!to || !text) { throw new Error('Recipient number (to) and message text cannot be empty.'); } // Add more robust E.164 validation if needed logger.info(`Attempting to send SMS from ${senderId} to ${to}`); try { const params = { src: senderId, dst: to, text: text, }; // Include status callback URL if provided if (callbackUrl) { params.url = callbackUrl; params.method = 'POST'; // Method Plivo uses to call your callback URL } const response = await client.messages.create(params); logger.info(`SMS submitted to Plivo for ${to}. Message UUID: ${response.messageUuid}`); // Note: This response indicates submission to Plivo, not final delivery. // Delivery status comes via webhook if configured. return { success: true, messageUuid: response.messageUuid }; } catch (error) { logger.error(`Error sending SMS via Plivo to ${to}: ${error.message}`, { error }); // Plivo errors often have more details in error.response or error.message throw error; // Re-throw for handling upstream (e.g., in the job queue worker) } }; // Function to generate Plivo XML for replying to incoming messages const createReplyXml = (responseText) => { const response = new plivo.Response(); response.addMessage(responseText); // Simple text reply return response.toXML(); }; // Function to validate incoming Plivo webhooks const validateWebhookSignature = (req) => { const signature = req.headers['x-plivo-signature-v3']; const nonce = req.headers['x-plivo-signature-v3-nonce']; // Construct the full URL Plivo used. // Note: This might be unreliable if behind certain proxies that rewrite URL components. // Ensure BASE_URL is correctly set to the public-facing URL Plivo hits. const url = process.env.BASE_URL + req.originalUrl; const method = req.method; const authToken = process.env.PLIVO_AUTH_TOKEN; if (!signature || !nonce) { logger.warn('Missing Plivo signature headers.'); return false; } try { // CRITICAL: Plivo's validation requires the raw, unparsed request body for POST/PUT. // If using express.json() globally *before* this check, `req.body` will be parsed JSON, // which WILL cause validation failure. // You MUST capture the raw body *before* JSON parsing for webhook routes. // See `plivoWebhookAuth.js` and `app.js` setup for handling this. // We assume here that the raw body (Buffer) is available via `req.rawBody` (or similar). const postParams = req.method === 'POST' ? (req.rawBody || req.body) : {}; // Prioritize rawBody if available // Note: Plivo SDK v4's validateV3Signature expects body params as an object for POST. // If using rawBody (Buffer), you might need to parse it *conditionally* ONLY for validation // if the SDK helper requires an object map, or check if the SDK offers a way to pass the raw buffer directly. // For simplicity, we pass `postParams` but emphasize the raw body requirement. const isValid = plivo.validateV3Signature(method, url, nonce, authToken, signature, postParams); // const isValid = plivo.Utils.validatesignature(url, nonce, signature, authToken); // Alternative if simpler validation is sufficient/available if (!isValid) { logger.warn('Invalid Plivo webhook signature received.', { url, method, nonce, signature, headers: req.headers, bodyUsed: postParams === req.rawBody ? 'rawBody' : 'req.body' }); } return isValid; } catch (error) { logger.error('Error validating Plivo webhook signature:', { error, url, method }); return false; } }; module.exports = { sendSms, createReplyXml, validateWebhookSignature, };
- We initialize the Plivo client using credentials from
.env
. sendSms
handles sending a single message, including optional status callback configuration.createReplyXml
generates the XML Plivo expects for automatic replies via webhooks.validateWebhookSignature
is crucial for securing your webhook endpoints. Implementation Note: This function requires the raw request body for validation to work correctly with POST requests. Ensure your middleware setup provides this (see Section 6). The URL construction usingBASE_URL + req.originalUrl
might also need adjustment if your application runs behind complex proxies.
- We initialize the Plivo client using credentials from
3. Database Schema and Data Layer
We'll use Prisma to define our database schema and interact with PostgreSQL.
-
Define Schema (
prisma/schema.prisma
): Update your schema file with models for Subscribers, Campaigns, and potentially Sent Messages for tracking.// prisma/schema.prisma generator client { provider = ""prisma-client-js"" } datasource db { provider = ""postgresql"" url = env(""DATABASE_URL"") } model Subscriber { id Int @id @default(autoincrement()) phoneNumber String @unique // E.164 format firstName String? lastName String? isActive Boolean @default(true) // For opt-outs createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Optional: Link to campaigns they are part of (many-to-many) // campaigns Campaign[] sentMessages SentMessage[] // Track messages sent to this subscriber } model Campaign { id Int @id @default(autoincrement()) name String messageBody String status String @default(""draft"") // e.g., draft, scheduled, sending, sent, failed scheduledAt DateTime? // For future scheduling sentAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Track messages sent for this campaign sentMessages SentMessage[] } model SentMessage { id Int @id @default(autoincrement()) campaignId Int subscriberId Int messageUuid String? @unique // Plivo's message identifier status String // e.g., queued, processing, submitted, delivered, failed, undelivered statusCallback Json? // Store the full callback payload from Plivo sentAt DateTime @default(now()) // Time job was queued/processed lastStatusAt DateTime? // Time of last status update from Plivo campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) // Cascade delete if campaign deleted subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Cascade) // Cascade delete if subscriber deleted @@index([campaignId]) @@index([subscriberId]) @@index([status]) @@index([messageUuid]) }
- Subscriber: Stores contact information and opt-out status (
isActive
). - Campaign: Defines the message content and tracks the overall campaign status.
- SentMessage: Logs each individual message attempt, linking Campaigns and Subscribers, storing the Plivo
messageUuid
, and tracking delivery status. AddedonDelete: Cascade
for referential integrity.
- Subscriber: Stores contact information and opt-out status (
-
Run Database Migration: Apply the schema changes to your database. Prisma creates SQL migration files.
# Create the migration files based on schema changes npx prisma migrate dev --name init_campaign_schema # This command will also apply the migration to your database. # Ensure your DATABASE_URL in .env is correct and the database server is running.
-
Generate Prisma Client: Whenever you change your schema, regenerate the Prisma Client.
npx prisma generate
This updates the
@prisma/client
library with typesafe methods based on your schema. -
Database Client Configuration (
src/config/db.js
):// src/config/db.js const { PrismaClient } = require('@prisma/client'); const logger = require('../utils/logger'); // Assuming logger is set up const prisma = new PrismaClient({ log: [ { emit: 'event', level: 'query' }, { emit: 'stdout', level: 'info' }, { emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }, ], }); prisma.$on('query', (e) => { // Log query performance, useful for debugging slow queries if (e.duration > 100) { // Log queries taking longer than 100ms logger.warn(`Slow Query (${e.duration}ms): ${e.query}`, { params: e.params }); } // logger.debug(`Query: ${e.query}`, { duration: e.duration, params: e.params }); // More verbose logging }); // Optional: Test connection on startup (can add complexity) async function testDbConnection() { try { await prisma.$connect(); logger.info('Database connection successful.'); } catch (error) { logger.error('Database connection failed:', error); process.exit(1); // Exit if DB connection fails on startup } finally { // $connect is idempotent, no need to disconnect here if used normally // await prisma.$disconnect(); } } // Call the test function if needed, e.g., in server.js before starting the server // testDbConnection(); module.exports = prisma;
This sets up a singleton Prisma client instance and includes basic query logging.
4. Implementing Core Functionality (API & Services)
Let's build the API endpoints and service logic for managing subscribers and campaigns, and for sending messages.
Structure:
- Routes (
src/routes/
): Define API endpoints and link them to controllers. Useexpress.Router
. - Controllers (
src/controllers/
): Handle HTTP requests, perform validation, call services, and send responses. - Services (
src/services/
): Contain the core business logic, interacting with the database (Prisma) and external services (Plivo).
4.1. Subscriber Management
-
Subscriber Routes (
src/routes/subscriberRoutes.js
):// src/routes/subscriberRoutes.js const express = require('express'); const subscriberController = require('../controllers/subscriberController'); const { validateSubscriber, validateIdParam } = require('../middleware/validators'); // We'll create this const authenticateApiKey = require('../middleware/authMiddleware'); // We'll create this const router = express.Router(); // Protect all subscriber routes with API key auth router.use(authenticateApiKey); router.post('/', validateSubscriber, subscriberController.createSubscriber); router.get('/', subscriberController.getAllSubscribers); router.get('/:id', validateIdParam, subscriberController.getSubscriberById); // Use PUT for full updates, PATCH for partial updates (like opt-out) router.patch('/:id', validateIdParam, validateSubscriber, subscriberController.updateSubscriber); // Allow partial updates router.delete('/:id', validateIdParam, subscriberController.deleteSubscriber); module.exports = router;
-
Subscriber Service (
src/services/subscriberService.js
):// src/services/subscriberService.js const prisma = require('../config/db'); const logger = require('../utils/logger'); const create = async (data) => { try { // Basic E.164 format check (improve as needed) if (!/^\+[1-9]\d{1,14}$/.test(data.phoneNumber)) { throw new Error('Invalid phone number format. Use E.164 (e.g., +14155551212)'); } const subscriber = await prisma.subscriber.create({ data }); logger.info(`Subscriber created: ${subscriber.id} - ${subscriber.phoneNumber}`); return subscriber; } catch (error) { if (error.code === 'P2002' && error.meta?.target?.includes('phoneNumber')) { logger.warn(`Attempted to create duplicate subscriber: ${data.phoneNumber}`); throw new Error(`Subscriber with phone number ${data.phoneNumber} already exists.`); } logger.error('Error creating subscriber:', error); throw error; } }; const findAll = async (activeOnly = true) => { const whereClause = activeOnly ? { isActive: true } : {}; return prisma.subscriber.findMany({ where: whereClause, orderBy: { createdAt: 'desc' } }); }; const findById = async (id) => { const subscriber = await prisma.subscriber.findUnique({ where: { id: parseInt(id, 10) } }); if (!subscriber) { throw new Error(`Subscriber with ID ${id} not found.`); } return subscriber; }; const update = async (id, data) => { try { if (data.phoneNumber && !/^\+[1-9]\d{1,14}$/.test(data.phoneNumber)) { throw new Error('Invalid phone number format. Use E.164.'); } // Ensure boolean values are handled correctly if passed as strings from form data if (data.isActive !== undefined && typeof data.isActive !== 'boolean') { data.isActive = !(data.isActive === 'false' || data.isActive === '0' || !data.isActive); } const subscriber = await prisma.subscriber.update({ where: { id: parseInt(id, 10) }, data, // Prisma handles partial updates automatically with `update` }); logger.info(`Subscriber updated: ${subscriber.id}`); return subscriber; } catch (error) { if (error.code === 'P2025') { // Record to update not found throw new Error(`Subscriber with ID ${id} not found for update.`); } if (error.code === 'P2002' && error.meta?.target?.includes('phoneNumber')) { logger.warn(`Attempted to update subscriber to duplicate number: ${data.phoneNumber}`); throw new Error(`Another subscriber with phone number ${data.phoneNumber} already exists.`); } logger.error(`Error updating subscriber ${id}:`, error); throw error; } }; // Handle opt-out specifically via phone number (used by webhook) const setOptOutStatus = async (phoneNumber, isOptedOut) => { try { // Ensure E.164 format for lookup if (!/^\+[1-9]\d{1,14}$/.test(phoneNumber)) { logger.warn(`Received opt-out/in request for invalid number format: ${phoneNumber}`); return null; // Or throw error } const subscriber = await prisma.subscriber.update({ where: { phoneNumber: phoneNumber }, data: { isActive: !isOptedOut }, }); logger.info(`Subscriber ${phoneNumber} opt-out status set to ${isOptedOut}`); return subscriber; } catch (error) { if (error.code === 'P2025') { // Record to update not found logger.warn(`Received opt-out/in request for unknown number: ${phoneNumber}`); // Decide if you want to create a subscriber record here or just log // Example: Create if opting in, ignore if opting out from unknown number // if (!isOptedOut) { ... create subscriber ... } return null; } logger.error(`Error setting opt-out status for ${phoneNumber}:`, error); throw error; // Rethrow to indicate failure } }; const remove = async (id) => { try { // Consider implications: deleting a subscriber might orphan SentMessage records // or violate constraints if not handled carefully (e.g., using onDelete: Cascade in schema) await prisma.subscriber.delete({ where: { id: parseInt(id, 10) } }); logger.info(`Subscriber deleted: ${id}`); return true; } catch (error) { if (error.code === 'P2025') { // Record to delete not found throw new Error(`Subscriber with ID ${id} not found for deletion.`); } // Handle foreign key constraint errors if cascade delete isn't set up (P2003) if (error.code === 'P2003') { logger.error(`Cannot delete subscriber ${id} due to related records (e.g., sent messages).`); throw new Error(`Cannot delete subscriber ${id} as they have related message history.`); } logger.error(`Error deleting subscriber ${id}:`, error); throw error; } }; module.exports = { create, findAll, findById, update, remove, setOptOutStatus };
-
Subscriber Controller (
src/controllers/subscriberController.js
):// src/controllers/subscriberController.js const subscriberService = require('../services/subscriberService'); const { validationResult } = require('express-validator'); exports.createSubscriber = async (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } try { // Ensure only allowed fields are passed to the service const allowedData = { phoneNumber: req.body.phoneNumber, firstName: req.body.firstName, lastName: req.body.lastName, isActive: req.body.isActive // Let service handle default if undefined }; const subscriber = await subscriberService.create(allowedData); res.status(201).json(subscriber); } catch (error) { // Handle specific ""already exists"" error if (error.message.includes('already exists')) { return res.status(409).json({ message: error.message }); // Conflict } next(error); // Pass other errors to global handler } }; exports.getAllSubscribers = async (req, res, next) => { try { // Allow filtering active subscribers via query param ?activeOnly=false const activeOnly = req.query.activeOnly !== 'false'; const subscribers = await subscriberService.findAll(activeOnly); res.status(200).json(subscribers); } catch (error) { next(error); } }; exports.getSubscriberById = async (req, res, next) => { const errors = validationResult(req); // Check param validation if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } try { const subscriber = await subscriberService.findById(req.params.id); res.status(200).json(subscriber); } catch (error) { // Handle specific ""not found"" error if (error.message.includes('not found')) { return res.status(404).json({ message: error.message }); } next(error); } }; exports.updateSubscriber = async (req, res, next) => { const errors = validationResult(req); // Check param and body validation if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } try { // Ensure only allowed fields are passed to the service const allowedData = {}; if (req.body.phoneNumber !== undefined) allowedData.phoneNumber = req.body.phoneNumber; if (req.body.firstName !== undefined) allowedData.firstName = req.body.firstName; if (req.body.lastName !== undefined) allowedData.lastName = req.body.lastName; if (req.body.isActive !== undefined) allowedData.isActive = req.body.isActive; if (Object.keys(allowedData).length === 0) { return res.status(400).json({ message: 'No valid fields provided for update.' }); } const subscriber = await subscriberService.update(req.params.id, allowedData); res.status(200).json(subscriber); } catch (error) { // Handle specific ""not found"" and ""already exists"" errors if (error.message.includes('not found')) { return res.status(404).json({ message: error.message }); } if (error.message.includes('already exists')) { return res.status(409).json({ message: error.message }); // Conflict } next(error); } }; exports.deleteSubscriber = async (req, res, next) => { const errors = validationResult(req); // Check param validation if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } try { await subscriberService.remove(req.params.id); res.status(204).send(); // No Content } catch (error) { // Handle specific ""not found"" and constraint violation errors if (error.message.includes('not found')) { return res.status(404).json({ message: error.message }); } if (error.message.includes('related message history')) { return res.status(409).json({ message: error.message }); // Conflict } next(error); } };