This guide provides a step-by-step walkthrough for building a RedwoodJS application capable of sending SMS marketing campaigns using the Infobip API. We will cover project setup, core SMS sending functionality, API integration, database modeling, error handling, security, testing, and deployment.
By the end of this tutorial, you will have a functional RedwoodJS application that can define simple SMS campaigns and send messages to a list of recipients via Infobip, along with the foundational knowledge to extend its capabilities.
Project Overview and Goals
What We're Building:
We are building a full-stack application using RedwoodJS that allows users (potentially administrators or marketers) to:
- Define basic SMS marketing campaigns (e.g., campaign name, message content).
- Associate recipients (phone numbers) with campaigns.
- Trigger the sending of SMS messages for a campaign via the Infobip API.
Problem Solved:
This application provides a basic framework for businesses needing to programmatically send targeted SMS messages for marketing or notifications, leveraging a robust provider like Infobip within the productive RedwoodJS ecosystem.
Technologies:
- RedwoodJS: A full-stack JavaScript/TypeScript framework for the modern web.
- Infobip: A global cloud communications platform offering various APIs, including a powerful SMS API.
- Prisma: A next-generation Node.js and TypeScript ORM used by RedwoodJS.
- GraphQL: A query language for APIs used by RedwoodJS.
- Node.js: The JavaScript runtime environment.
- Yarn: Package manager for Node.js.
- PostgreSQL (Recommended for Production) / SQLite (Default for Dev): Relational database systems.
System Architecture:
graph LR
A[User Browser] -- GraphQL --> B(RedwoodJS API Side / GraphQL Server);
B -- Prisma Client --> C(Database - PostgreSQL/SQLite);
B -- REST API Call --> D(Infobip SMS API);
C -- Stores --> E(Campaigns & Recipients Data);
D -- Sends --> F(SMS Messages);
subgraph RedwoodJS App
direction LR
G(RedwoodJS Web Side / React UI) --- A;
B --- G;
end
- User Browser: Interacts with the React frontend (RedwoodJS Web Side).
- RedwoodJS Web Side: Handles UI rendering and sends GraphQL requests to the API side.
- RedwoodJS API Side: Contains the GraphQL server, business logic (services), and database access layer (Prisma).
- Database: Stores application data like campaign details and recipient lists.
- Infobip SMS API: External service responsible for actually sending the SMS messages.
Prerequisites:
- Node.js (v18 or later recommended)
- Yarn (v1/Classic is the default for new Redwood projects, but Yarn Berry (v3+) or other package managers like npm/pnpm can also be configured if preferred)
- An Infobip Account (Sign up at Infobip)
- Basic understanding of JavaScript/TypeScript, React, GraphQL, and databases.
- Access to a terminal or command prompt.
Expected Outcome:
A RedwoodJS application where you can trigger an API mutation (e.g., via the GraphQL playground) to send a predefined SMS message to a list of phone numbers using your Infobip account.
1. Setting up the Project
Let's initialize our RedwoodJS project and configure the essential components.
Create RedwoodJS App
Open your terminal and run the following command to create a new RedwoodJS project. We'll use TypeScript.
yarn create redwood-app ./redwood-infobip-sms --typescript
Navigate to Project Directory
cd redwood-infobip-sms
Environment Variables
RedwoodJS uses .env
files for environment variables. Create a .env
file in the project root.
-
.env
(Git-ignored, for sensitive local development keys):# .env # Replace placeholder values with your actual Infobip credentials # Obtain these from your Infobip account dashboard INFOBIP_API_KEY=""PASTE_YOUR_INFOBIP_API_KEY_HERE"" INFOBIP_BASE_URL=""https://youruniqueid.api.infobip.com"" # Example: Replace with your actual Base URL INFOBIP_SENDER_ID=""MyBrand"" # Example: Replace with your registered Sender ID or number # Database URL (Example for PostgreSQL - replace user, password, host, dbname) DATABASE_URL=""postgresql://db_user:db_password@localhost:5432/redwood_infobip_db?schema=public"" # Database URL (Example for SQLite - default for Redwood dev) # DATABASE_URL=""file:./dev.db"" # Add a secure session secret for auth (generate a random string) SESSION_SECRET=""replace-this-with-a-very-long-random-string-32-chars-or-more""
-
.env.defaults
(Committed to Git, for non-sensitive defaults):# .env.defaults # Default sender ID if not overridden in .env (optional) INFOBIP_SENDER_ID=""InfoSMS""
-
Purpose: Using
.env
keeps sensitive credentials like API keys out of your codebase and version control..env.defaults
provides non-sensitive defaults. Redwood automatically loads these. Ensure.env
is listed in your.gitignore
.
Database Configuration (Prisma)
The database connection is defined by DATABASE_URL
in .env
. The schema is in api/db/schema.prisma
. We'll define models later.
Initialize Database
Apply the initial Prisma schema to create the database (for SQLite) or ensure connection (for PostgreSQL).
yarn rw prisma migrate dev
# You will be prompted to name the migration, e.g., ""initial setup""
- Purpose: This command synchronizes your database schema with
schema.prisma
and generates the Prisma Client.
2. Implementing Core Functionality (SMS Service)
The core logic for interacting with Infobip will reside in a RedwoodJS service.
Install HTTP Client
We need axios
to make HTTP requests to the Infobip API.
yarn workspace api add axios
Generate Campaign Service
Use Redwood's generator. We use --crud
to quickly scaffold the service file, tests, and SDL, even though we'll replace the initial CRUD functions with our custom logic first.
yarn rw g service campaign --crud
- This creates
api/src/services/campaigns/campaigns.ts
,campaigns.test.ts
,campaigns.sdl.ts
, and related files. We will modify these.
Implement SMS Sending Logic
Open api/src/services/campaigns/campaigns.ts
. Replace the generated CRUD function placeholders with a function to send SMS via Infobip. We will add database-interacting functions later.
// api/src/services/campaigns/campaigns.ts
import axios from 'axios'
import type { Prisma } from '@prisma/client' // Will be used later
import { db } from 'src/lib/db' // Will be used later
import { logger } from 'src/lib/logger'
interface SendSmsPayload {
to: string[] // Array of recipient phone numbers in E.164 format
text: string
from?: string // Optional sender ID, defaults to env var
}
// Note: This function will be renamed to `sendSms` to match the GraphQL mutation later
export const sendSmsViaInfobip = async ({ to, text, from }: SendSmsPayload) => {
const apiKey = process.env.INFOBIP_API_KEY
const baseUrl = process.env.INFOBIP_BASE_URL
// Use the provided 'from', fallback to env var, then a default
const senderId = from || process.env.INFOBIP_SENDER_ID || 'InfoSMS'
if (!apiKey || !baseUrl) {
logger.error('Infobip API Key or Base URL missing in environment variables.')
throw new Error('Infobip configuration is incomplete.')
}
// Basic validation
if (!to || to.length === 0 || !text) {
logger.error('Missing required parameters: to (list of numbers) and text.')
throw new Error('Recipient numbers and message text are required.')
}
// Add more validation here (e.g., phone number format, text length) if needed
const infobipUrl = `${baseUrl}/sms/2/text/advanced`
// Structure the payload according to Infobip API V2 documentation
// Ref: https://www.infobip.com/docs/api/channels/sms/sms-messaging/outbound-sms/send-sms-message
const payload = {
messages: to.map((recipient) => ({
destinations: [{ to: recipient }],
from: senderId,
text: text,
})),
}
try {
logger.info(`Sending SMS via Infobip to ${to.length} recipients.`)
logger.debug({ payload: { messages: payload.messages.map(m => ({...m, text: '[REDACTED]'})) } }, 'Infobip Request Payload (Text Redacted):') // Avoid logging full text
const response = await axios.post(infobipUrl, payload, {
headers: {
Authorization: `App ${apiKey}`, // Use 'App' prefix for API Key method
'Content-Type': 'application/json',
Accept: 'application/json',
},
timeout: 10000, // 10 second timeout
})
logger.info(
{ bulkId: response.data.bulkId, status: response.status },
'Infobip SMS submitted successfully.'
)
// Return relevant info, like the bulk ID for tracking
return {
success: true,
bulkId: response.data.bulkId,
messages: response.data.messages, // Contains status for each message
}
} catch (error) {
logger.error({ error }, 'Failed to send SMS via Infobip.')
if (axios.isAxiosError(error)) {
logger.error(
{
status: error.response?.status,
data: error.response?.data,
// headers: error.response?.headers, // Avoid logging potentially sensitive headers
},
'Infobip API Error Details'
)
// Provide a more user-friendly error message if possible
const errorMessage = error.response?.data?.requestError?.serviceException?.text || error.message;
throw new Error(`Infobip API request failed: ${errorMessage}`)
} else {
throw new Error(`An unexpected error occurred: ${error.message}`)
}
}
}
// --- Placeholder for Campaign CRUD functions (to be added in Section 6) ---
// export const campaigns = () => { ... }
// export const campaign = ({ id }: Prisma.CampaignWhereUniqueInput) => { ... }
// export const createCampaign = ({ input }: { input: Prisma.CampaignCreateInput }) => { ... }
// export const updateCampaign = ({ id, input }: { id: number; input: Prisma.CampaignUpdateInput }) => { ... }
// export const deleteCampaign = ({ id }: Prisma.CampaignWhereUniqueInput) => { ... }
// --- Placeholder for triggerCampaignSend function (to be added in Section 6) ---
// export const triggerCampaignSend = async ({ input }: { input: TriggerCampaignSendInput }) => { ... }
- Why this approach? Encapsulates Infobip logic, uses environment variables,
axios
for HTTP, follows Infobip API structure, includes basic validation, logging, and error handling.
3. Building the API Layer (GraphQL)
Expose backend functionality via GraphQL.
Define GraphQL Schema (SDL)
Modify api/src/graphql/campaigns.sdl.ts
to define the mutation and its input/output types.
# api/src/graphql/campaigns.sdl.ts
export const schema = gql`
# Type returned after attempting to send SMS
type SendSmsResponse {
success: Boolean!
bulkId: String # Infobip's ID for the batch submission
message: String # Optional message for non-bulk results
# Optional: Add individual message statuses if needed
# messages: [InfobipMessageStatus]
}
# Input for the sendSms mutation
input SendSmsInput {
to: [String!]! # Array of phone numbers (E.164 format required)
text: String!
from: String # Optional: Override default sender ID
}
# Define Mutations (actions that change data or trigger processes)
type Mutation {
""""""
Triggers sending an SMS message to multiple recipients via Infobip.
Requires authentication. Use for ad-hoc sends not tied to a stored campaign.
""""""
sendSms(input: SendSmsInput!): SendSmsResponse! @requireAuth
}
# --- Placeholder Types/Queries/Mutations for Campaigns (added in Section 6) ---
# type Campaign { ... }
# type Recipient { ... }
# type Query { ... }
# input CreateCampaignInput { ... }
# input UpdateCampaignInput { ... }
# input TriggerCampaignSendInput { ... }
# type Mutation {
# createCampaign(...)
# updateCampaign(...)
# deleteCampaign(...)
# triggerCampaignSend(...)
# }
# --- End Placeholders ---
`
@requireAuth
: Ensures only authenticated users can call this. Set up Redwood Auth (e.g.,dbAuth
) or remove temporarily for initial testing without auth.
Link Service to Mutation
Rename the service function to match the mutation name (sendSms
).
- In
api/src/services/campaigns/campaigns.ts
, renamesendSmsViaInfobip
tosendSms
.
// api/src/services/campaigns/campaigns.ts
// ... imports ...
interface SendSmsInput { // Renamed from SendSmsPayload to match SDL
to: string[]
text: string
from?: string
}
// Renamed function to match SDL mutation name 'sendSms'
// The input argument is automatically destructured by Redwood { input }
export const sendSms = async ({ input }: { input: SendSmsInput }) => {
const { to, text, from } = input // Destructure from the input object
const apiKey = process.env.INFOBIP_API_KEY
const baseUrl = process.env.INFOBIP_BASE_URL
const senderId = from || process.env.INFOBIP_SENDER_ID || 'InfoSMS'
// ... (rest of the validation and payload logic remains the same) ...
const infobipUrl = `${baseUrl}/sms/2/text/advanced`
const payload = {
messages: to.map((recipient) => ({
destinations: [{ to: recipient }],
from: senderId,
text: text,
})),
}
try {
logger.info(`Sending SMS via Infobip to ${to.length} recipients.`)
logger.debug({ payload: { messages: payload.messages.map(m => ({...m, text: '[REDACTED]'})) } }, 'Infobip Request Payload (Text Redacted):')
const response = await axios.post(infobipUrl, payload, {
headers: {
Authorization: `App ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
timeout: 10000,
})
logger.info(
{ bulkId: response.data.bulkId, status: response.status },
'Infobip SMS submitted successfully.'
)
// Ensure the return matches SendSmsResponse type in SDL
return {
success: true,
bulkId: response.data.bulkId,
// messages: response.data.messages, // Optional detailed statuses
message: `SMS submitted successfully to ${to.length} numbers.` // Add optional message
}
} catch (error) {
logger.error({ error }, 'Failed to send SMS via Infobip.')
if (axios.isAxiosError(error)) {
logger.error(
{
status: error.response?.status,
data: error.response?.data,
},
'Infobip API Error Details'
)
const errorMessage = error.response?.data?.requestError?.serviceException?.text || error.message;
throw new Error(`Infobip API request failed: ${errorMessage}`)
} else {
throw new Error(`An unexpected error occurred: ${error.message}`)
}
// Re-throw the error after logging so GraphQL reports it
// throw error; // Original code had this commented out, but re-throwing is usually correct for GraphQL
}
}
// ... rest of the file ...
Testing with GraphQL Playground
- Start the dev server:
yarn rw dev
- Open
http://localhost:8911/graphql
. - Execute the mutation (replace placeholders with real, E.164 formatted numbers for testing):
mutation SendTestSms {
# Note: This will fail if @requireAuth is active and you haven't set up auth
sendSms(
input: {
# Replace with actual E.164 numbers you can test with
to: [""+15551234567"", ""+15559876543""]
text: ""Hello from RedwoodJS and Infobip!""
# from: ""OptionalSender"" # Optionally override sender from .env
}
) {
success
bulkId
message
}
}
- Check your terminal logs for success/errors and the Infobip portal for message status.
4. Integrating with Infobip (Configuration Details)
Details on obtaining and managing Infobip credentials.
Obtaining Infobip Credentials
- API Key:
- Log in to your Infobip account portal.
- Navigate to API Key management (often under developer settings or account).
- Create a new API key, name it descriptively.
- Copy the key immediately and store it securely.
- Place this key into your
.env
file asINFOBIP_API_KEY
.
- Base URL:
- Find your account-specific Base URL in the Infobip portal/documentation (e.g.,
https://your-unique-id.api.infobip.com
). - Place this URL into your
.env
file asINFOBIP_BASE_URL
.
- Find your account-specific Base URL in the Infobip portal/documentation (e.g.,
- Sender ID (
from
number/name):- This is the identifier shown to recipients.
- Regulations vary greatly by country (alphanumeric, short code, long code, toll-free).
- You may need to register/purchase Sender IDs/numbers via the Infobip portal.
- Crucially, consult Infobip documentation or support regarding Sender ID requirements and registration processes for your target countries.
- Set a default in
.env
or.env.defaults
(INFOBIP_SENDER_ID
).
Secure Storage
- Never commit
.env
files or API keys to Git. Ensure.env
is in.gitignore
. - Use your hosting provider's secrets management for production environments (Vercel, Netlify, AWS Secrets Manager, etc.).
Environment Variables Summary
INFOBIP_API_KEY
: Your secret API key. Obtain: Infobip Portal.INFOBIP_BASE_URL
: Your account-specific API URL. Obtain: Infobip Portal/Docs.INFOBIP_SENDER_ID
: Default sender ID/number. Configure: Infobip Portal (subject to regulations).DATABASE_URL
: Database connection string. Obtain: Database provider/setup.SESSION_SECRET
: Secure random string for session management (used by Redwood auth). Generate one.
Fallback Mechanisms
- Consider retries (Section 5), circuit breakers (
opossum
), or alternative providers for critical systems, though this adds complexity.
5. Error Handling, Logging, and Retry Mechanisms
Building resilience into the application.
Consistent Error Handling Strategy
- Our service uses
try...catch
, logs errors with context (logger.error
), and re-throws errors for GraphQL to handle.
Logging
- Use Redwood's Pino logger (
src/lib/logger.ts
). - Use levels appropriately:
info
(success),warn
(recoverable issues, retries),error
(failures),debug
(verbose dev info). - Redwood's JSON logs are ideal for aggregation services (Datadog, Logflare, etc.).
- Filter/search logs by
bulkId
, error messages, status codes.
Retry Mechanism (Simple Example)
Add basic retries with exponential backoff to sendSms
for transient network or server errors.
// api/src/services/campaigns/campaigns.ts
import axios from 'axios'
// ... other imports ...
import { logger } from 'src/lib/logger'
// Helper function for async delay
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// ... interface SendSmsInput ...
export const sendSms = async ({ input }: { input: SendSmsInput }) => {
const { to, text, from } = input
const apiKey = process.env.INFOBIP_API_KEY
const baseUrl = process.env.INFOBIP_BASE_URL
const senderId = from || process.env.INFOBIP_SENDER_ID || 'InfoSMS'
// ... validation ...
if (!apiKey || !baseUrl) {
logger.error('Infobip API Key or Base URL missing in environment variables.')
throw new Error('Infobip configuration is incomplete.')
}
if (!to || to.length === 0 || !text) {
logger.error('Missing required parameters: to (list of numbers) and text.')
throw new Error('Recipient numbers and message text are required.')
}
const infobipUrl = `${baseUrl}/sms/2/text/advanced`
const payload = {
messages: to.map((recipient) => ({
destinations: [{ to: recipient }],
from: senderId,
text: text,
})),
}
const MAX_RETRIES = 3;
let attempt = 0;
while (attempt < MAX_RETRIES) {
attempt++;
try {
logger.info(`Sending SMS via Infobip (Attempt ${attempt}/${MAX_RETRIES}) to ${to.length} recipients.`);
// logger.debug({ payload }_ 'Infobip Request Payload:'); // Be cautious logging payload
const response = await axios.post(infobipUrl_ payload_ {
headers: {
Authorization: `App ${apiKey}`_
'Content-Type': 'application/json'_
Accept: 'application/json'_
}_
timeout: 15000 // Slightly longer timeout for retries
});
logger.info(
{ bulkId: response.data.bulkId_ status: response.status_ attempt }_
'Infobip SMS submitted successfully.'
);
return {
success: true_
bulkId: response.data.bulkId_
message: `SMS submitted successfully on attempt ${attempt}.`_
// messages: response.data.messages_
};
} catch (error) {
logger.warn({ error: error.message_ attempt_ maxRetries: MAX_RETRIES }_ `Attempt ${attempt} failed for Infobip SMS.`);
let shouldRetry = false;
// Exponential backoff: 1s_ 2s_ 4s... + some jitter
let waitMs = 1000 * Math.pow(2_ attempt - 1) + Math.random() * 500;
if (axios.isAxiosError(error)) {
const status = error.response?.status;
// Retry on network errors or specific server errors (e.g._ 5xx)
if (!status || (status >= 500 && status <= 599)) {
shouldRetry = true;
logger.warn(`Network or server error (${status || 'N/A'})_ scheduling retry.`);
} else {
// Log details for non-retryable client errors (4xx) or others
logger.error({ status: error.response?.status_ data: error.response?.data }_ 'Non-retryable Infobip API Error Details');
}
} else {
// Non-axios error (e.g._ code error before request)
logger.error({ error }_ 'Non-Axios error during Infobip request attempt.');
// Generally don't retry these unless specifically handled
}
if (shouldRetry && attempt < MAX_RETRIES) {
logger.info(`Waiting ${Math.round(waitMs)}ms before next retry.`);
await delay(waitMs);
// Continue to next iteration of the loop
} else {
// Last attempt failed or non-retryable error occurred
logger.error({ error: error.message_ attempt }_ 'Failed to send SMS via Infobip after all retries or due to non-retryable error.');
// Construct a meaningful error to throw
let finalError;
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data?.requestError?.serviceException?.text || error.message;
finalError = new Error(`Infobip API request failed after ${attempt} attempts: ${errorMessage}`);
} else {
finalError = new Error(`An unexpected error occurred after ${attempt} attempts: ${error.message}`);
}
throw finalError; // Throw the aggregated/final error
}
}
}
// This line should technically be unreachable if the logic above is correct_
// as the loop either returns successfully or throws an error.
// Included primarily for type safety / exhaustive checks in some setups.
// throw new Error('SMS sending failed unexpectedly after retry loop.');
}
// ... rest of file ...
- Exponential Backoff: Prevents overwhelming services during transient issues.
- Consider libraries like
async-retry
for more complex retry strategies.
Testing Error Scenarios
- Temporarily use an invalid API key (
.env
) -> Expect 401 error. - Simulate network issues -> Expect retry logic to engage.
- Send invalid phone numbers/payload -> Expect 400 error from Infobip.
6. Creating a Database Schema and Data Layer
Define models for campaigns and recipients.
Define Prisma Schema
Update api/db/schema.prisma
.
// api/db/schema.prisma
datasource db {
provider = ""postgresql"" // Or ""sqlite""
url = env(""DATABASE_URL"")
}
generator client {
provider = ""prisma-client-js""
binaryTargets = ""native"" // Or specific targets like ""debian-openssl-1.1.x"" for deployment
}
model Recipient {
id Int @id @default(autoincrement())
phoneNumber String @unique // E.164 format REQUIRED
firstName String?
lastName String?
isOptedOut Boolean @default(false) // Added for opt-out tracking
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Many-to-Many relationship with Campaign
campaigns Campaign[] @relation(""CampaignRecipients"")
@@index([isOptedOut]) // Index for filtering active recipients
}
model Campaign {
id Int @id @default(autoincrement())
name String @unique
message String
senderId String? // Campaign-specific sender override
// Suggested statuses: DRAFT, SCHEDULED, SENDING, SENT, FAILED, COMPLETE_NO_RECIPIENTS
status String @default(""DRAFT"")
scheduledAt DateTime?
sentAt DateTime? // Timestamp when sending was initiated/completed
infobipBulkId String? // Store Infobip's bulk ID for tracking
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Many-to-Many relationship with Recipient
recipients Recipient[] @relation(""CampaignRecipients"")
@@index([status])
@@index([scheduledAt])
}
// Prisma implicitly handles the join table (_CampaignRecipients)
// based on the @relation definition above.
isOptedOut
field added toRecipient
.- Status list expanded in comments.
- Indexes added for common query fields.
Database Migrations
Apply schema changes.
yarn rw prisma migrate dev
# Provide migration name, e.g., ""add campaign recipient models optout""
This updates the DB schema and regenerates the Prisma Client.
Implement Data Access (CRUD Operations)
Add CRUD functions and the triggerCampaignSend
logic to api/src/services/campaigns/campaigns.ts
.
// api/src/services/campaigns/campaigns.ts
import axios from 'axios'
import type { Prisma, Campaign, Recipient } from '@prisma/client' // Import types
import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'
import { requireAuth } from 'src/lib/auth' // Assuming auth is set up
// --- Helper: delay function (from Section 5) ---
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// --- Interface: SendSmsInput (from Section 3) ---
interface SendSmsInput { to: string[]; text: string; from?: string; }
// --- Service Function: sendSms (with retries, from Section 5) ---
export const sendSms = async ({ input }: { input: SendSmsInput }) => {
// ... existing sendSms logic with retries ...
const { to, text, from } = input
const apiKey = process.env.INFOBIP_API_KEY
const baseUrl = process.env.INFOBIP_BASE_URL
const senderId = from || process.env.INFOBIP_SENDER_ID || 'InfoSMS'
if (!apiKey || !baseUrl) {
logger.error('Infobip API Key or Base URL missing in environment variables.')
throw new Error('Infobip configuration is incomplete.')
}
if (!to || to.length === 0 || !text) {
logger.error('Missing required parameters: to (list of numbers) and text.')
throw new Error('Recipient numbers and message text are required.')
}
const infobipUrl = `${baseUrl}/sms/2/text/advanced`
const payload = {
messages: to.map((recipient) => ({
destinations: [{ to: recipient }],
from: senderId,
text: text,
})),
}
const MAX_RETRIES = 3;
let attempt = 0;
while (attempt < MAX_RETRIES) {
attempt++;
try {
logger.info(`Sending SMS via Infobip (Attempt ${attempt}/${MAX_RETRIES}) to ${to.length} recipients.`);
const response = await axios.post(infobipUrl_ payload_ {
headers: {
Authorization: `App ${apiKey}`_
'Content-Type': 'application/json'_
Accept: 'application/json'_
}_
timeout: 15000
});
logger.info(
{ bulkId: response.data.bulkId_ status: response.status_ attempt }_
'Infobip SMS submitted successfully.'
);
return {
success: true_
bulkId: response.data.bulkId_
message: `SMS submitted successfully on attempt ${attempt}.`_
};
} catch (error) {
logger.warn({ error: error.message_ attempt_ maxRetries: MAX_RETRIES }_ `Attempt ${attempt} failed for Infobip SMS.`);
let shouldRetry = false;
let waitMs = 1000 * Math.pow(2_ attempt - 1) + Math.random() * 500;
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (!status || (status >= 500 && status <= 599)) {
shouldRetry = true;
logger.warn(`Network or server error (${status || 'N/A'})_ scheduling retry.`);
} else {
logger.error({ status: error.response?.status_ data: error.response?.data }_ 'Non-retryable Infobip API Error Details');
}
} else {
logger.error({ error }_ 'Non-Axios error during Infobip request attempt.');
}
if (shouldRetry && attempt < MAX_RETRIES) {
logger.info(`Waiting ${Math.round(waitMs)}ms before next retry.`);
await delay(waitMs);
} else {
logger.error({ error: error.message_ attempt }_ 'Failed to send SMS via Infobip after all retries or due to non-retryable error.');
let finalError;
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data?.requestError?.serviceException?.text || error.message;
finalError = new Error(`Infobip API request failed after ${attempt} attempts: ${errorMessage}`);
} else {
finalError = new Error(`An unexpected error occurred after ${attempt} attempts: ${error.message}`);
}
throw finalError;
}
}
}
// Should be unreachable
throw new Error('SMS sending failed unexpectedly after retry loop.');
}
// --- Campaign CRUD Functions ---
/** Returns a list of campaigns (consider pagination for production). */
export const campaigns = () => {
requireAuth({ roles: ['admin', 'marketer'] }) // Example role check
return db.campaign.findMany({ orderBy: { createdAt: 'desc' } })
}
/** Returns a single campaign by ID, including its recipients. */
export const campaign = ({ id }: Prisma.CampaignWhereUniqueInput) => {
requireAuth()
return db.campaign.findUnique({
where: { id },
include: {
recipients: { // Optionally filter/paginate recipients here too
orderBy: { createdAt: 'asc' }
}
},
})
}
// Use Prisma generated types for better safety
interface CreateCampaignServiceInput {
input: Omit<Prisma.CampaignCreateInput_ 'recipients'> & { // Base Campaign data
recipientIds?: number[] // Accept array of Recipient IDs
}
}
/** Creates a new campaign. Optionally connects recipients by ID. */
export const createCampaign = async ({ input }: CreateCampaignServiceInput) => {
requireAuth({ roles: ['admin', 'marketer'] })
const { recipientIds, ...campaignData } = input
return db.campaign.create({
data: {
...campaignData, // Spread basic fields like name, message, status
status: campaignData.status || 'DRAFT', // Ensure default status
// Connect recipients if IDs are provided
recipients: recipientIds && recipientIds.length > 0
? { connect: recipientIds.map((id) => ({ id })) }
: undefined,
},
})
}
interface UpdateCampaignServiceInput {
id: number;
input: Omit<Prisma.CampaignUpdateInput_ 'recipients'> & {
recipientIds?: number[] // Allow updating connected recipients
}
}
/** Updates an existing campaign. Allows replacing the recipient list. */
export const updateCampaign = ({ id, input }: UpdateCampaignServiceInput) => {
requireAuth({ roles: ['admin', 'marketer'] })
const { recipientIds, ...campaignData } = input
return db.campaign.update({
where: { id },
data: {
...campaignData,
// Use 'set' to replace the entire list of connected recipients
// Use connect/disconnect for adding/removing specific ones if needed
recipients: recipientIds
? { set: recipientIds.map((id) => ({ id })) }
: undefined, // If recipientIds is omitted, don't change recipients
},
})
}
/** Deletes a campaign by its ID. */
export const deleteCampaign = ({ id }: Prisma.CampaignWhereUniqueInput) => {
requireAuth({ roles: ['admin'] }) // Stricter role for deletion
// Add checks: prevent deletion of 'SENDING' or recently 'SENT' campaigns?
return db.campaign.delete({
where: { id },
})
}
// --- Trigger Campaign Sending Logic ---
interface TriggerCampaignSendInput {
campaignId: number
}
/** Fetches campaign details and sends SMS to its ACTIVE recipients. */
export const triggerCampaignSend = async ({ input }: { input: TriggerCampaignSendInput }) => {
requireAuth({ roles: ['admin', 'marketer'] })
const { campaignId } = input;
logger.info({ campaignId }, ""Attempting to trigger campaign send."");
// Fetch campaign WITH recipients
const camp = await db.campaign.findUnique({
where: { id: campaignId },
include: { recipients: true } // Include related recipients
});
if (!camp) {
logger.error({ campaignId }, ""Campaign not found for triggering send."");
throw new Error(`Campaign with ID ${campaignId} not found.`);
}
// Filter out opted-out recipients
const activeRecipients = camp.recipients.filter(r => !r.isOptedOut);
if (activeRecipients.length === 0) {
logger.warn({ campaignId }, ""Campaign has no active (opted-in) recipients. Nothing to send."");
// Update status to reflect this outcome
await db.campaign.update({
where: { id: campaignId },
data: { status: 'COMPLETE_NO_RECIPIENTS', sentAt: new Date() } // Mark as completed
});
return { success: true, message: ""Campaign has no active recipients."" };
}
// Prevent re-sending or sending while already in progress
if (camp.status === 'SENDING' || camp.status === 'SENT') {
logger.warn({ campaignId, status: camp.status }, ""Campaign is already sending or has been sent. Send trigger ignored."");
// Return success:false or throw an error depending on desired behavior
return { success: false, message: `Campaign status is already ${camp.status}. Send trigger ignored.` };
}
// Update campaign status to SENDING before initiating send
await db.campaign.update({
where: { id: campaignId },
data: { status: 'SENDING', sentAt: new Date() } // Mark start time
});
let sendResult;
try {
const recipientNumbers = activeRecipients.map(r => r.phoneNumber);
const sender = camp.senderId; // Use campaign-specific sender if set
// Call the core SMS sending function (which includes retries)
sendResult = await sendSms({
input: {
to: recipientNumbers,
text: camp.message,
from: sender // Pass campaign sender override if available
}
});
// Update campaign status to SENT (or potentially FAILED_PARTIAL if needed)
await db.campaign.update({
where: { id: campaignId },
data: {
status: 'SENT', // Mark as sent after successful submission to Infobip
infobipBulkId: sendResult.bulkId // Store the bulk ID
}
});
logger.info({ campaignId, bulkId: sendResult.bulkId, recipientCount: recipientNumbers.length }, ""Campaign SMS successfully submitted to Infobip."");
return { success: true, bulkId: sendResult.bulkId, message: `Campaign ${campaignId} sent to ${recipientNumbers.length} recipients.` };
} catch (error) {
logger.error({ campaignId, error: error.message }, ""Failed to send campaign SMS via Infobip."");
// Update campaign status to FAILED
await db.campaign.update({
where: { id: campaignId },
data: { status: 'FAILED' } // Mark as failed
});
// Re-throw the error so the GraphQL mutation reports failure
throw new Error(`Failed to send campaign ${campaignId}: ${error.message}`);
}
}
</p>
</p>