Developer Guide: Building SMS Marketing Campaigns with Fastify and Plivo
This guide provides a step-by-step walkthrough for building a production-ready SMS marketing campaign application using the Fastify framework for Node.js and Plivo's messaging platform APIs. We will cover everything from initial project setup to deployment and monitoring.
By the end of this tutorial, you will have a functional backend service capable of managing subscriber lists, creating SMS marketing campaigns, and sending bulk messages via Plivo, incorporating best practices for security, error handling, and scalability.
Project Overview and Goals
What We're Building:
We are creating a backend API service using Fastify. This service will manage:
- Subscribers: Storing phone numbers and consent status for receiving marketing messages.
- Campaigns: Defining marketing messages and targeting specific subscriber groups (though for simplicity, we'll initially target all opted-in subscribers).
- Sending: Triggering the dispatch of campaign messages to opted-in subscribers via the Plivo SMS API.
Problem Solved:
This application provides the core infrastructure needed for businesses to leverage SMS marketing effectively. It centralizes subscriber management, ensures consent is handled, and integrates with a reliable communication platform (Plivo) for message delivery.
Technologies Used:
- Node.js: The JavaScript runtime environment.
- Fastify: A high-performance, low-overhead Node.js web framework. Chosen for its speed, extensibility, and developer-friendly features (like built-in validation and logging).
- Plivo: A cloud communications platform providing SMS API capabilities. Chosen for its reliable delivery, developer tools, and scalability.
- PostgreSQL: A robust open-source relational database.
- Prisma: A modern ORM for Node.js and TypeScript, simplifying database interactions.
- Docker: For containerizing the application for consistent deployment.
- Fly.io: (Example deployment platform) A platform for deploying full-stack apps and databases.
- GitHub Actions: For continuous integration and deployment (CI/CD).
System Architecture:
+-------------+ +-----------------+ +-------------+
| Client | ----> | Fastify API | ----> | Plivo API |
| (e.g., Web) | | (This Project) | | (for SMS) |
+-------------+ +--------+--------+ +-------------+
|
|
v
+---------------+
| PostgreSQL |
| (Database) |
+---------------+
Prerequisites:
- Node.js (LTS version recommended) and npm/yarn installed.
- Access to a PostgreSQL database instance, including connection details (URL string with username, password, host, port, database name) for configuration.
- A Plivo account with Auth ID, Auth Token, and a Plivo phone number capable of sending SMS.
- Docker installed locally (for containerization and deployment).
- Fly.io CLI installed (optional, for deployment).
- Basic familiarity with Node.js, REST APIs, and SQL.
Final Outcome:
A containerized Fastify application exposing RESTful API endpoints for managing subscribers and campaigns, capable of sending SMS messages via Plivo. The application will include logging, basic error handling, security measures, and deployment configurations.
1. Setting up the Project
Let's initialize our Fastify project and set up the basic structure.
-
Create Project Directory:
mkdir fastify-plivo-sms cd fastify-plivo-sms
-
Initialize Node.js Project:
npm init -y
-
Install Core Dependencies:
npm install fastify fastify-env fastify-sensible fastify-cors pino-pretty @prisma/client plivo async-retry libphonenumber-js fastify-rate-limit @fastify/helmet @fastify/sentry @sentry/node @sentry/tracing # For potential async/job queue implementation (optional but recommended for production bulk sending): # npm install bullmq ioredis
fastify
: The core framework.fastify-env
: For managing environment variables.fastify-sensible
: Adds useful decorators likehttpErrors
.fastify-cors
: Enables Cross-Origin Resource Sharing.pino-pretty
: Developer-friendly logger formatting (for development).@prisma/client
: Prisma's database client.plivo
: The official Plivo Node.js SDK.async-retry
: For adding retry logic to Plivo calls.libphonenumber-js
: For robust phone number validation/formatting.fastify-rate-limit
: For API rate limiting.@fastify/helmet
: For security headers.@fastify/sentry
,@sentry/node
,@sentry/tracing
: For error tracking with Sentry.bullmq
,ioredis
: (Optional) Recommended for production-grade background job processing.
-
Install Development Dependencies:
npm install --save-dev prisma nodemon tap
prisma
: The Prisma CLI for migrations and generation.nodemon
: Automatically restarts the server during development.tap
: Fastify's recommended testing framework.
-
Configure
package.json
Scripts: Update thescripts
section in yourpackage.json
:// package.json "scripts": { "test": "tap 'test/**/*.test.js'", "start": "node src/server.js", "dev": "nodemon --watch src --exec 'node --inspect=127.0.0.1:9229' src/server.js | pino-pretty", "db:seed": "node scripts/seed.js", "prisma:migrate": "npx prisma migrate dev", "prisma:generate": "npx prisma generate", "prisma:deploy": "npx prisma migrate deploy" },
test
: Runs tests usingtap
.start
: Runs the application in production mode.dev
: Runs the application in development mode withnodemon
for auto-reloads,pino-pretty
for readable logs, and the Node inspector attached to127.0.0.1
(localhost only) for security.db:seed
: Runs the database seeding script.prisma:*
: Helper scripts for Prisma operations.
-
Initialize Prisma:
npx prisma init --datasource-provider postgresql
This creates a
prisma
directory with aschema.prisma
file and a.env
file for your database connection string. -
Configure Environment Variables (
.env
): Update the generated.env
file. Add placeholders for Plivo credentials and other settings.# .env # Database Connection (Prisma uses this) # Example: postgresql://user:password@host:port/database?schema=public DATABASE_URL="postgresql://postgres:yourpassword@localhost:5432/sms_marketing?schema=public" # Application Settings HOST=0.0.0.0 PORT=3000 LOG_LEVEL=info # Use 'debug' for more verbose logging NODE_ENV=development # Set to 'production' in deployed environments # Plivo Credentials PLIVO_AUTH_ID="YOUR_PLIVO_AUTH_ID" PLIVO_AUTH_TOKEN="YOUR_PLIVO_AUTH_TOKEN" # Also used for webhook signature validation PLIVO_SENDER_NUMBER="YOUR_PLIVO_PHONE_NUMBER" # Must be SMS-enabled # Sentry DSN (Optional - for error tracking) SENTRY_DSN="" # Add any other environment variables as needed
- Important: Replace placeholders with your actual database URL and Plivo credentials. Never commit your
.env
file with real secrets to version control. Add.env
to your.gitignore
file.
- Important: Replace placeholders with your actual database URL and Plivo credentials. Never commit your
-
Project Structure: Create the following directory structure:
fastify-plivo-sms/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── plugins/ │ ├── routes/ │ ├── services/ │ ├── app.js # Main Fastify application setup │ └── server.js # Entry point to start the server ├── test/ ├── scripts/ │ └── seed.js # Database seeding script ├── .github/ │ └── workflows/ │ └── deploy.yml # Example CI/CD workflow ├── .env ├── .gitignore ├── Dockerfile ├── fly.toml # Example deployment config └── package.json
-
Basic Fastify App Setup (
src/app.js
):// src/app.js 'use strict' const path = require('path') const Fastify = require('fastify') const sensible = require('fastify-sensible') const env = require('fastify-env') const cors = require('fastify-cors') const autoload = require('fastify-autoload') const helmet = require('@fastify/helmet') const rateLimit = require('fastify-rate-limit') const Sentry = require('@sentry/node') // Define environment variable schema const schema = { type: 'object', required: [ 'PORT', 'HOST', 'DATABASE_URL', 'PLIVO_AUTH_ID', 'PLIVO_AUTH_TOKEN', 'PLIVO_SENDER_NUMBER' ], properties: { PORT: { type: 'string', default: 3000 }, HOST: { type: 'string', default: '0.0.0.0' }, DATABASE_URL: { type: 'string' }, LOG_LEVEL: { type: 'string', default: 'info'}, NODE_ENV: { type: 'string', default: 'development' }, PLIVO_AUTH_ID: { type: 'string' }, PLIVO_AUTH_TOKEN: { type: 'string' }, PLIVO_SENDER_NUMBER: { type: 'string' }, SENTRY_DSN: { type: 'string', default: '' } // Optional }, } const envOptions = { confKey: 'config', // Access variables via `fastify.config` schema: schema, dotenv: true // Load .env file } async function build (opts = {}) { const fastify = Fastify({ logger: { level: process.env.LOG_LEVEL || 'info', // Default log level // Use pino-pretty in dev, JSON in production ...(process.env.NODE_ENV !== 'production' && { transport: { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss Z', ignore: 'pid,hostname', }, }, }), }, ...opts }) // Register essential plugins FIRST await fastify.register(env, envOptions) // Initialize Sentry (early, using env vars from fastify.config) if (fastify.config.SENTRY_DSN) { fastify.register(require('@fastify/sentry'), { dsn: fastify.config.SENTRY_DSN, environment: fastify.config.NODE_ENV || 'development', // Add other Sentry config as needed (release, tracesSampleRate) }); fastify.log.info('Sentry plugin registered.'); } else { fastify.log.warn('SENTRY_DSN not found, Sentry integration disabled.'); } await fastify.register(sensible) // Adds useful utilities like fastify.httpErrors await fastify.register(cors, { origin: '*', // Configure allowed origins appropriately for production methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] }) await fastify.register(helmet, { // Disable CSP if not needed or configured separately via reverse proxy contentSecurityPolicy: false }); await fastify.register(rateLimit, { max: 100, // Max requests per windowMs per key timeWindow: '1 minute', errorResponseBuilder: (req, context) => ({ code: 429, error: 'Too Many Requests', message: `Rate limit exceeded. Retry after ${context.after}` }) // Consider using Redis for distributed rate limiting: // redis: new require('ioredis')(process.env.REDIS_URL) }); // Autoload plugins (like database, plivo client) await fastify.register(autoload, { dir: path.join(__dirname, 'plugins'), options: { ...opts, config: fastify.config } // Pass config to plugins }) // Autoload routes (including webhooks) await fastify.register(autoload, { dir: path.join(__dirname, 'routes'), options: { prefix: '/api/v1' } // Prefix API routes }) // Load health check route without prefix await fastify.register(require('./routes/health')) // Load webhook route without prefix for easier configuration in Plivo await fastify.register(require('./routes/webhooks'), { prefix: '/webhooks' }) fastify.log.info('Application plugins and routes loaded.') return fastify } module.exports = { build }
- Why
fastify-env
? It validates required environment variables on startup, preventing runtime errors due to missing configuration. - Why
fastify-sensible
? Provides standard HTTP error objects (fastify.httpErrors.notFound()
) and other utilities. - Why
fastify-autoload
? Simplifies loading plugins and routes from separate directories, keepingapp.js
clean. - Security Plugins:
helmet
andrateLimit
added early. - Error Tracking: Sentry initialized early.
- Route Loading: API routes prefixed, health and webhook routes loaded separately without prefix.
- Why
-
Server Entry Point (
src/server.js
):// src/server.js 'use strict' // If using Sentry, require it early for tracing instrumentation if (process.env.SENTRY_DSN) { require('@sentry/node'); } const { build } = require('./app') async function start () { let fastify try { fastify = await build() await fastify.ready() // Ensure all plugins are loaded const host = fastify.config.HOST const port = fastify.config.PORT await fastify.listen({ port: port, host: host }) // Use object syntax for listen } catch (err) { console.error('Error starting server:', err) if (fastify) { fastify.log.error(err) } else { // If Fastify didn't even initialize, log directly console.error(err); } process.exit(1) } } start()
-
Add
.gitignore
: Create a.gitignore
file in the root directory:# .gitignore node_modules/ .env dist/ coverage/ npm-debug.log* yarn-debug.log* yarn-error.log* *.local # Prisma generated client (usually in node_modules, but good to ignore explicitly if output changes) # node_modules/@prisma/client/ # Prisma migration SQL files (usually not committed, schema is the source of truth) # If you *do* want to commit SQL for review, remove or adjust this line. prisma/migrations/**/*.sql !prisma/migrations/migration_lock.toml # Fly.io state (if applicable) .fly/
- Note on
prisma/migrations/*.sql
: This line prevents generated SQL migration files from being committed. This is standard practice as theschema.prisma
file is the source of truth, and migrations are generated from it. If your team workflow requires committing the SQL files for review, you can remove this line.
- Note on
You now have a basic Fastify project structure ready for adding features. Run npm run dev
to start the development server. You should see log output indicating the server is listening.
2. Implementing Core Functionality
We'll start by defining our database schema and creating services for managing subscribers and campaigns.
-
Define Database Schema (
prisma/schema.prisma
):// prisma/schema.prisma generator client { provider = ""prisma-client-js"" } datasource db { provider = ""postgresql"" url = env(""DATABASE_URL"") } model Subscriber { id String @id @default(cuid()) // Unique ID phoneNumber String @unique // Subscriber's phone number (E.164 format recommended) isOptedIn Boolean @default(true) // Consent status for marketing createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([isOptedIn]) // Index for filtering opted-in subscribers } model Campaign { id String @id @default(cuid()) name String // Internal name for the campaign messageBody String // The SMS message content sentAt DateTime? // Timestamp when the campaign was sent createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
- Why CUID? Provides unique, sortable identifiers.
- Why
@unique
onphoneNumber
? Prevents duplicate subscriber entries. - Why
isOptedIn
? Crucial for tracking consent, a legal requirement (e.g., TCPA, GDPR). - Why
@@index([isOptedIn])
? Added an index to potentially speed up queries filtering by opt-in status, which is common.
-
Apply Database Migrations: Create the initial migration and apply it to your database.
# Create the migration files based on schema changes npm run prisma:migrate -- --name init # Generate the Prisma client types npm run prisma:generate
Prisma will create the
Subscriber
andCampaign
tables in your PostgreSQL database. -
Create Prisma Plugin (
src/plugins/prisma.js
): This plugin initializes the Prisma client and makes it available throughout the Fastify application.// src/plugins/prisma.js 'use strict' const fp = require('fastify-plugin') const { PrismaClient } = require('@prisma/client') async function prismaPlugin (fastify, options) { const prisma = new PrismaClient({ log: fastify.config.LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'], }) try { await prisma.$connect() fastify.log.info('Prisma client connected successfully.') } catch (err) { fastify.log.error('Prisma client connection error:', err) throw new Error('Failed to connect to database') // Prevent server start if DB fails } // Make Prisma Client available through decorator fastify.decorate('prisma', prisma) // Graceful shutdown fastify.addHook('onClose', async (instance) => { instance.log.info('Disconnecting Prisma client...') await instance.prisma.$disconnect() instance.log.info('Prisma client disconnected.') }) } module.exports = fp(prismaPlugin)
- Why
fastify-plugin
? Ensures the plugin is loaded correctly and decorators (fastify.prisma
) are available globally. - Why
onClose
hook? Ensures the database connection is closed cleanly when the server shuts down.
- Why
-
Create Subscriber Service (
src/services/subscriberService.js
): This service encapsulates the logic for interacting with theSubscriber
model, now usinglibphonenumber-js
.// src/services/subscriberService.js 'use strict' const { parsePhoneNumberFromString } = require('libphonenumber-js'); class SubscriberService { constructor (prisma) { this.prisma = prisma } _validateAndFormatNumber(phoneNumber) { if (typeof phoneNumber !== 'string') { throw new Error('Phone number must be a string.'); } const parsed = parsePhoneNumberFromString(phoneNumber); if (!parsed || !parsed.isValid()) { throw new Error('Invalid phone number format.'); } // Store consistently in E.164 format return parsed.format('E.164'); } async createSubscriber (inputPhoneNumber) { const formattedNumber = this._validateAndFormatNumber(inputPhoneNumber); return this.prisma.subscriber.create({ data: { phoneNumber: formattedNumber, isOptedIn: true, // Default to opted-in on creation, confirm consent via other means }, }) } async getSubscriberById (id) { return this.prisma.subscriber.findUnique({ where: { id } }) } async getSubscriberByPhoneNumber (inputPhoneNumber) { // Validate format before querying const formattedNumber = this._validateAndFormatNumber(inputPhoneNumber); return this.prisma.subscriber.findUnique({ where: { phoneNumber: formattedNumber } }) } async getAllSubscribers (filterOptedIn = null) { const whereClause = {}; if (filterOptedIn !== null) { whereClause.isOptedIn = Boolean(filterOptedIn); } return this.prisma.subscriber.findMany({ where: whereClause, orderBy: { createdAt: 'desc' } // Optional: order by creation date }); } async updateOptInStatus (id, isOptedIn) { return this.prisma.subscriber.update({ where: { id }, data: { isOptedIn: Boolean(isOptedIn) }, }) } async updateOptInStatusByPhoneNumber (inputPhoneNumber, isOptedIn) { const formattedNumber = this._validateAndFormatNumber(inputPhoneNumber); // Use updateMany to avoid fetching first (more efficient if number is guaranteed unique) // Or stick to findUnique + update if you need the subscriber object returned potentially. const result = await this.prisma.subscriber.updateMany({ where: { phoneNumber: formattedNumber }, data: { isOptedIn: Boolean(isOptedIn) }, }); if (result.count === 0) { throw new Error('Subscriber not found.'); } // Note: updateMany doesn't return the record, just the count. // If you need the updated record, you'd need to query again or use findUnique + update. return { phoneNumber: formattedNumber, isOptedIn: Boolean(isOptedIn), updatedCount: result.count }; } async deleteSubscriber (id) { return this.prisma.subscriber.delete({ where: { id } }) } } module.exports = SubscriberService
- Validation: Uses
libphonenumber-js
for robust validation and formatting to E.164 standard.
- Validation: Uses
-
Create Campaign Service (
src/services/campaignService.js
): (No changes from original, logic remains the same)// src/services/campaignService.js 'use strict' class CampaignService { constructor (prisma) { this.prisma = prisma } async createCampaign (name, messageBody) { if (!name || !messageBody) { throw new Error('Campaign name and message body are required.') } return this.prisma.campaign.create({ data: { name, messageBody, }, }) } async getCampaignById (id) { return this.prisma.campaign.findUnique({ where: { id } }) } async getAllCampaigns () { return this.prisma.campaign.findMany({ orderBy: { createdAt: 'desc' }}) } async markCampaignAsSent (id) { return this.prisma.campaign.update({ where: { id }, data: { sentAt: new Date() }, }) } async deleteCampaign (id) { // Consider implications: Should deleting prevent sending history? // Maybe add a 'deletedAt' field instead (soft delete). return this.prisma.campaign.delete({ where: { id } }); } } module.exports = CampaignService
These services provide a clean abstraction layer over the database operations. We'll use them in our API routes next.
3. Building a Complete API Layer
Now, let's expose our services through Fastify routes with proper request validation.
-
Subscriber Routes (
src/routes/subscribers.js
):// src/routes/subscribers.js 'use strict' const SubscriberService = require('../services/subscriberService') // Validation Schemas const createSubscriberSchema = { body: { type: 'object', required: ['phoneNumber'], properties: { // Use a broader string type here; service layer handles validation/formatting phoneNumber: { type: 'string', description: 'Phone number, preferably in E.164 format (e.g., +15551234567)' }, }, }, response: { 201: { $ref: 'subscriber#' } // Reference shared schema } } const updateOptInSchema = { params: { type: 'object', required: ['id'], properties: { id: { type: 'string', format: 'cuid' } } // Add format hint if using CUIDs }, body: { type: 'object', required: ['isOptedIn'], properties: { isOptedIn: { type: 'boolean' }, }, }, response: { 200: { $ref: 'subscriber#' } } } const subscriberParamsSchema = { params: { type: 'object', required: ['id'], properties: { id: { type: 'string', format: 'cuid' } } } } const listSubscribersSchema = { querystring: { type: 'object', properties: { optedIn: { type: 'boolean' } } }, response: { 200: { type: 'array', items: { $ref: 'subscriber#' } } } } module.exports = async function (fastify, opts) { // Instantiate service with Prisma client from fastify instance const subscriberService = new SubscriberService(fastify.prisma) // Shared Schema for Subscriber response fastify.addSchema({ $id: 'subscriber', type: 'object', properties: { id: { type: 'string', format: 'cuid' }, phoneNumber: { type: 'string', format: 'e164' }, // Indicate E.164 format isOptedIn: { type: 'boolean' }, createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' } } }) // --- Routes --- // POST /api/v1/subscribers - Create a new subscriber fastify.post('/', { schema: createSubscriberSchema }, async (request, reply) => { try { const { phoneNumber } = request.body // Service layer now handles validation and checks for duplicates based on formatted number const subscriber = await subscriberService.createSubscriber(phoneNumber) return reply.code(201).send(subscriber) } catch (err) { request.log.error({ err }, 'Failed to create subscriber') if (err.message.includes('Invalid phone number')) { return reply.badRequest(err.message); } // Handle Prisma unique constraint error (P2002) if (err.code === 'P2002' && err.meta?.target?.includes('phoneNumber')) { // Extract the formatted number from the error if possible, or use input // This requires knowing the exact error structure or re-validating return reply.conflict(`Subscriber with phone number already exists.`); } throw fastify.httpErrors.internalServerError('Could not create subscriber.') } }) // GET /api/v1/subscribers - List subscribers (optionally filter by optedIn status) fastify.get('/', { schema: listSubscribersSchema }, async (request, reply) => { try { const { optedIn } = request.query; // optedIn will be true, false, or undefined const filter = optedIn === undefined ? null : optedIn; const subscribers = await subscriberService.getAllSubscribers(filter); return subscribers; } catch (err) { request.log.error({ err }, 'Failed to retrieve subscribers'); throw fastify.httpErrors.internalServerError('Could not retrieve subscribers.'); } }); // GET /api/v1/subscribers/:id - Get a specific subscriber fastify.get('/:id', { schema: { params: subscriberParamsSchema.params, response: { 200: { $ref: 'subscriber#'}} }}, async (request, reply) => { try { const { id } = request.params; const subscriber = await subscriberService.getSubscriberById(id); if (!subscriber) { throw fastify.httpErrors.notFound('Subscriber not found.'); } return subscriber; } catch (err) { if (err.statusCode === 404) throw err; // Re-throw not found errors request.log.error({ err, subscriberId: request.params.id }, 'Failed to retrieve subscriber'); throw fastify.httpErrors.internalServerError('Could not retrieve subscriber.'); } }); // PUT /api/v1/subscribers/:id/optin - Update opt-in status fastify.put('/:id/optin', { schema: updateOptInSchema }, async (request, reply) => { try { const { id } = request.params const { isOptedIn } = request.body const subscriber = await subscriberService.updateOptInStatus(id, isOptedIn) return subscriber } catch (err) { request.log.error({ err, subscriberId: request.params.id }, 'Failed to update subscriber opt-in status'); if (err.code === 'P2025') { // Prisma record not found error throw fastify.httpErrors.notFound('Subscriber not found.'); } throw fastify.httpErrors.internalServerError('Could not update subscriber.'); } }) // DELETE /api/v1/subscribers/:id - Delete a subscriber fastify.delete('/:id', { schema: { params: subscriberParamsSchema.params, response: { 204: {} } }}, async (request, reply) => { try { const { id } = request.params; await subscriberService.deleteSubscriber(id); return reply.code(204).send(); // No Content } catch (err) { request.log.error({ err, subscriberId: request.params.id }, 'Failed to delete subscriber'); if (err.code === 'P2025') { // Prisma record not found error throw fastify.httpErrors.notFound('Subscriber not found.'); } throw fastify.httpErrors.internalServerError('Could not delete subscriber.'); } }); }
- Schema Validation: Updated schema to accept a broader string for
phoneNumber
at the API layer, relying on the service layer for E.164 validation and formatting. Addedformat
hints for CUID and E.164. - Error Handling: Adjusted error handling for unique constraints, acknowledging that the service layer handles validation.
- Schema Validation: Updated schema to accept a broader string for
-
Campaign Routes (
src/routes/campaigns.js
): (Schema and basic CRUD routes remain similar, focus on the/send
endpoint)// src/routes/campaigns.js 'use strict' const CampaignService = require('../services/campaignService') const SubscriberService = require('../services/subscriberService'); const PlivoService = require('../services/plivoService'); // Validation Schemas const createCampaignSchema = { body: { type: 'object', required: ['name', 'messageBody'], properties: { name: { type: 'string', minLength: 1 }, messageBody: { type: 'string', minLength: 1, maxLength: 1600 }, // Consider SMS limits }, }, response: { 201: { $ref: 'campaign#' } } } const campaignParamsSchema = { params: { type: 'object', required: ['id'], properties: { id: { type: 'string', format: 'cuid' } } } } const listCampaignsSchema = { response: { 200: { type: 'array', items: { $ref: 'campaign#' } } } } // Updated response for /send to reflect async nature (even if underlying code is sync for demo) const sendCampaignSchema = { params: campaignParamsSchema.params, response: { 202: { // Use 202 Accepted for async operations type: 'object', properties: { message: { type: 'string' }, campaignId: { type: 'string' }, status: { type: 'string', enum: ['queued', 'processing_sync'] }, // Indicate sync processing for this demo version estimated_subscribers: { type: 'integer' } } } } } module.exports = async function (fastify, opts) { const campaignService = new CampaignService(fastify.prisma) const subscriberService = new SubscriberService(fastify.prisma); // Pass Plivo client, sender number, and logger to PlivoService