Building a Bulk SMS Broadcast System with RedwoodJS and Infobip
This guide provides a step-by-step walkthrough for building a robust bulk SMS broadcasting application using the RedwoodJS framework and the Infobip API. We will create a system that enables users to input a list of phone numbers and a message, send the broadcast via Infobip, and track its status.
This solution addresses the need for businesses to efficiently send targeted SMS communications – such as notifications, alerts, or marketing messages – to multiple recipients simultaneously.
Technologies Used:
- RedwoodJS: A full-stack JavaScript/TypeScript framework that combines React, GraphQL, Prisma, and Jest into a seamless development experience. Chosen for its rapid development capabilities, integrated tooling, and clear architectural patterns (distinct frontend/backend sides).
- Infobip: A global communication platform offering APIs for various channels, including SMS. Chosen for its reliable SMS delivery, comprehensive API features, and ability to handle bulk messaging.
- Prisma: A next-generation Node.js and TypeScript ORM used by RedwoodJS for database access.
- PostgreSQL: A powerful, open-source object-relational database system (though Prisma supports others like MySQL, SQLite).
- Node.js & Yarn: The runtime environment and package manager.
System Architecture:
(Note: The following diagram uses Mermaid syntax. Rendering may vary depending on the platform.)
graph LR
A[User's Browser] -- Interacts --> B(RedwoodJS Web Side - React UI);
B -- Sends GraphQL Mutation --> C(RedwoodJS API Side - GraphQL Server);
C -- Calls Service Logic --> D(RedwoodJS Service - broadcasts.js);
D -- Interacts with Database via Prisma --> E[(Database - PostgreSQL)];
D -- Makes API Request --> F(Infobip SMS API);
F -- Sends SMS --> G((Recipients));
F -- Returns Response --> D;
D -- Updates Status --> E;
C -- Returns GraphQL Response --> B;
B -- Updates UI --> A;
Final Outcome:
By the end of this guide, you will have a functional RedwoodJS application with:
- A UI form to create new SMS broadcasts (recipients, message).
- An API endpoint to trigger the broadcast via Infobip.
- Database storage for broadcast details and status.
- Secure handling of Infobip API credentials.
- Basic error handling and logging.
Prerequisites:
- Node.js (>=18.x recommended)
- Yarn (v1)
- Access to a PostgreSQL database instance. (For local development, tools like Docker can be used to easily run a PostgreSQL instance.)
- An active Infobip account with API access enabled.
- Basic familiarity with JavaScript, React, and GraphQL concepts.
1. Setting up the Project
Let's initialize our RedwoodJS project and configure the essentials.
-
Create RedwoodJS App: Open your terminal and run:
yarn create redwood-app redwood-infobip-broadcast cd redwood-infobip-broadcast
Choose TypeScript if prompted (recommended, but JavaScript works too).
-
Database Configuration: Redwood uses Prisma. Configure your database connection string in the
.env
file at the project root. Replace the placeholder with your actual PostgreSQL connection URL:# .env # Use DATABASE_URL to configure your database connection # Example: DATABASE_URL=""postgresql://user:password@host:port/database?schema=public"" DATABASE_URL=""postgresql://postgres:your_password@localhost:5432/infobip_broadcast?schema=public""
Ensure your PostgreSQL server is running and the specified database (
infobip_broadcast
in this example) exists. -
Initial Migration: Apply the initial database schema (which is empty by default but sets up Prisma's migration tracking):
yarn rw prisma migrate dev
Enter a name for the migration when prompted (e.g.,
initial_setup
). -
Infobip Environment Variables: Add your Infobip credentials to the
.env
file. You'll also need to specify which environment variables theweb
side needs access to (prefixed withREDWOOD_ENV_
).# .env (add these lines) # Infobip API Credentials (API side only) INFOBIP_API_KEY=""YOUR_INFOBIP_API_KEY"" INFOBIP_BASE_URL=""YOUR_INFOBIP_REGION_BASE_URL"" # e.g., https://xyz.api.infobip.com INFOBIP_SENDER_ID=""YourSenderID"" # Optional but often required/recommended # No REDWOOD_ENV_ prefix needed for API-side variables
INFOBIP_API_KEY
: Found in your Infobip account under API Key management.INFOBIP_BASE_URL
: Specific to your account's region. Find this in the Infobip API documentation or your account settings (e.g.,https://<your-subdomain>.api.infobip.com
).INFOBIP_SENDER_ID
: The alphanumeric sender ID or phone number registered and approved in your Infobip account for sending messages. This might be optional depending on your Infobip setup and destination country regulations, but it's best practice to include it.
Obtaining Infobip Credentials:
- Log in to your Infobip Portal.
- Navigate to the section managing API Keys (often under account settings or developer tools). Generate a new key if needed.
- Locate your Base URL. This is often displayed prominently in the API documentation section or specific API key details. It's crucial to use the correct regional URL.
- Configure Sender IDs (numeric or alphanumeric) under the relevant channel settings (e.g., SMS) within the portal. Ensure they are approved for use.
2. Implementing Core Functionality
We'll now define the database model, create the API service and GraphQL schema, and build the UI component.
-
Define Database Schema: Update the Prisma schema file (
api/db/schema.prisma
) to include aBroadcast
model.// api/db/schema.prisma datasource db { provider = ""postgresql"" url = env(""DATABASE_URL"") } generator client { provider = ""prisma-client-js"" binaryTargets = ""native"" } // Define your own datamodels here and run `yarn redwood prisma migrate dev` // to create migrations for them and apply to your dev DB. model Broadcast { id Int @id @default(autoincrement()) message String recipients String[] // Store recipients as an array of strings (phone numbers) status String @default(""PENDING"") // e.g., PENDING, SENT, FAILED infobipBulkId String? // To store the ID returned by Infobip for the bulk request errorMessage String? // To store any error messages from Infobip createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
- We use
String[]
to store multiple recipient phone numbers. status
tracks the broadcast progress.infobipBulkId
helps correlate with Infobip's system.
- We use
-
Apply Schema Changes: Generate and apply the migration:
yarn rw prisma migrate dev
Enter a name like
add_broadcast_model
. -
Generate Redwood Files: Use Redwood's generators to scaffold the necessary GraphQL SDL, service, and UI components for managing broadcasts.
yarn rw g sdl Broadcast # This generates: api/src/graphql/broadcasts.sdl.ts # api/src/services/broadcasts/broadcasts.ts # api/src/services/broadcasts/broadcasts.scenarios.ts # api/src/services/broadcasts/broadcasts.test.ts yarn rw g page Broadcast /broadcasts # This generates: web/src/pages/BroadcastPage/BroadcastPage.tsx (and related files) yarn rw g cell Broadcasts # This generates: web/src/components/BroadcastsCell/BroadcastsCell.tsx (and related files) yarn rw g component BroadcastForm # This generates: web/src/components/BroadcastForm/BroadcastForm.tsx (and related files)
-
Implement the Broadcast Service: Modify the generated service file (
api/src/services/broadcasts/broadcasts.ts
) to include the logic for creating a broadcast and interacting with the Infobip API.// api/src/services/broadcasts/broadcasts.ts import type { QueryResolvers, MutationResolvers } from 'types/graphql' import { fetch } from 'cross-undici-fetch' // Use cross-undici-fetch for Node.js compatibility import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' // Import Redwood's logger // IMPORTANT: Verify these interfaces against the current Infobip API documentation // for the `POST /sms/2/text/advanced` endpoint, as APIs can change. interface InfobipDestination { to: string } interface InfobipMessage { from?: string // Optional: Sender ID destinations: InfobipDestination[] text: string } interface InfobipRequestBody { messages: InfobipMessage[] } interface InfobipSuccessResponse { bulkId: string messages: { to: string status: { groupId: number groupName: string id: number name: string description: string } messageId: string }[] } interface InfobipErrorResponse { requestError: { serviceException: { messageId: string text: string } } } // Helper function to validate E.164 format (basic example) const isValidE164 = (phoneNumber: string): boolean => { return /^\+[1-9]\d{1,14}$/.test(phoneNumber); } export const broadcasts: QueryResolvers['broadcasts'] = () => { return db.broadcast.findMany({ orderBy: { createdAt: 'desc' } }) } export const broadcast: QueryResolvers['broadcast'] = ({ id }) => { return db.broadcast.findUnique({ where: { id }, }) } export const createBroadcast: MutationResolvers['createBroadcast'] = async ({ input, }) => { logger.info({ input }, 'Attempting to create broadcast') const { message, recipients } = input // --- Input Validation --- if (!message || message.trim() === '') { throw new Error('Message content cannot be empty.') } if (!recipients || recipients.length === 0) { throw new Error('Recipients list cannot be empty.') } const invalidNumbers = recipients.filter(num => !isValidE164(num.trim())) if (invalidNumbers.length > 0) { throw new Error(`Invalid phone number format found: ${invalidNumbers.join(', ')}. Numbers must be in E.164 format (e.g., +14155552671).`) } // --- End Input Validation --- const apiKey = process.env.INFOBIP_API_KEY const baseUrl = process.env.INFOBIP_BASE_URL const senderId = process.env.INFOBIP_SENDER_ID // Optional sender if (!apiKey || !baseUrl) { logger.error('Infobip API Key or Base URL missing in environment variables.') throw new Error( 'Server configuration error: Infobip credentials not set.' ) } // 1. Create the broadcast record in our DB first (Status: PENDING) let createdBroadcast try { createdBroadcast = await db.broadcast.create({ data: { message, recipients: recipients.map(r => r.trim()), // Ensure trimmed numbers status: 'PENDING', }, }) logger.info( { broadcastId: createdBroadcast.id }, 'Broadcast record created in DB' ) } catch (dbError) { logger.error({ error: dbError }, 'Failed to create broadcast record in DB') throw new Error('Failed to initiate broadcast process.') } // 2. Prepare and send the request to Infobip const infobipDestinations: InfobipDestination[] = createdBroadcast.recipients.map( (recipient) => ({ to: recipient, }) ) const infobipRequestBody: InfobipRequestBody = { messages: [ { ...(senderId && { from: senderId }), // Include sender ID if configured destinations: infobipDestinations, text: createdBroadcast.message, }, ], } try { logger.info({ url: `${baseUrl}/sms/2/text/advanced` }, 'Sending request to Infobip') const response = await fetch(`${baseUrl}/sms/2/text/advanced`, { method: 'POST', headers: { Authorization: `App ${apiKey}`, // Use 'App' prefix for API Key v2 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify(infobipRequestBody), }) const responseBody = (await response.json()) as | InfobipSuccessResponse | InfobipErrorResponse if (!response.ok) { logger.error( { status: response.status, body: responseBody }, 'Infobip API request failed' ) const errorMessage = (responseBody as InfobipErrorResponse)?.requestError?.serviceException ?.text || `HTTP error ${response.status}` // Update DB record to FAILED await db.broadcast.update({ where: { id: createdBroadcast.id }, data: { status: 'FAILED', errorMessage: errorMessage }, }) throw new Error(`Infobip API Error: ${errorMessage}`) } // Success Case const successResponse = responseBody as InfobipSuccessResponse logger.info( { bulkId: successResponse.bulkId, messages: successResponse.messages }, 'Infobip request successful' ) // Update DB record to SENT with Infobip's bulk ID await db.broadcast.update({ where: { id: createdBroadcast.id }, data: { status: 'SENT', // Initial status, delivery reports would refine this infobipBulkId: successResponse.bulkId, errorMessage: null, }, }) // Return the updated record return await db.broadcast.findUnique({ where: { id: createdBroadcast.id } }) } catch (error) { logger.error({ error, broadcastId: createdBroadcast.id }, 'Error during Infobip API call or DB update') // Attempt to mark as FAILED if not already done try { const current = await db.broadcast.findUnique({ where: {id: createdBroadcast.id }}); if (current && current.status !== 'FAILED') { await db.broadcast.update({ where: { id: createdBroadcast.id }, data: { status: 'FAILED', errorMessage: error.message || 'Unknown error during processing', }, }) } } catch (updateError) { logger.error({ updateError, broadcastId: createdBroadcast.id }, 'Failed to update broadcast status to FAILED after error'); } // Re-throw the original error to the client throw error } } // Note: updateBroadcast and deleteBroadcast might not be strictly necessary // for sending, but the SDL generator creates them. Keep or remove as needed. // Implement them if you want to edit/delete broadcast records. // export const updateBroadcast: MutationResolvers['updateBroadcast'] = ({ // id, // input, // }) => { ... } // export const deleteBroadcast: MutationResolvers['deleteBroadcast'] = ({ id }) => { ... }
- We import
fetch
fromcross-undici-fetch
for reliable fetching in Node.js. - We add input validation for the message and recipient list (including E.164 format check).
- We create the DB record first with
PENDING
status. - We structure the API request according to Infobip's
POST /sms/2/text/advanced
endpoint. Note theAuthorization: App <YOUR_API_KEY>
header format. - We handle both success and error responses from Infobip.
- On success, we update the DB record status to
SENT
and store thebulkId
. - On failure, we update the status to
FAILED
and store the error message. - Robust
try...catch
blocks and logging are used throughout.
- We import
-
Define GraphQL Schema (SDL): Update the generated SDL file (
api/src/graphql/broadcasts.sdl.ts
) to match our service logic and model.// api/src/graphql/broadcasts.sdl.ts export const schema = gql` type Broadcast { id: Int! message: String! recipients: [String!]! status: String! infobipBulkId: String errorMessage: String createdAt: DateTime! updatedAt: DateTime! } type Query { broadcasts: [Broadcast!]! @requireAuth broadcast(id: Int!): Broadcast @requireAuth } input CreateBroadcastInput { message: String! recipients: [String!]! # Expect an array of phone numbers } # Input for updating is less likely needed for sending, but generated # input UpdateBroadcastInput { # message: String # recipients: [String!] # status: String # infobipBulkId: String # errorMessage: String # } type Mutation { createBroadcast(input: CreateBroadcastInput!): Broadcast! @requireAuth # updateBroadcast(id: Int!, input: UpdateBroadcastInput!): Broadcast! @requireAuth # deleteBroadcast(id: Int!): Broadcast! @requireAuth } `
- We define the
Broadcast
type mirroring the Prisma model. CreateBroadcastInput
expectsmessage
and an array ofrecipients
.- We apply
@requireAuth
to protect the endpoints. Remove@requireAuth
if you are testing without authentication configured.
- We define the
3. Building the API Layer (RedwoodJS GraphQL)
RedwoodJS automatically builds the GraphQL API based on your SDL files and service implementations.
- Endpoints: Your GraphQL endpoint is available at
/.redwood/functions/graphql
(or/api/graphql
in production builds). - Authentication/Authorization: We added
@requireAuth
to the SDL. To make this work, you need to set up authentication (e.g., usingyarn rw setup auth dbAuth
or another provider). If you keep@requireAuth
, the guide implicitly assumes a standard RedwoodJS authentication setup (likedbAuth
) is configured. See RedwoodJS Authentication documentation for setup instructions. For initial testing without auth setup, you can temporarily remove@requireAuth
from the SDL. - Request Validation: Basic validation (presence, E.164 format) was added directly in the
createBroadcast
service. For more complex validation, consider libraries likeyup
orzod
within the service layer.
Testing the API Endpoint:
-
Start the Dev Server:
yarn rw dev
-
Access GraphQL Playground: Open your browser to
http://localhost:8911/graphql
(or the GraphQL endpoint URL shown in the terminal). -
Run the
createBroadcast
Mutation: Use the Playground interface to send a mutation. Replace placeholders with real (test) E.164 numbers and your message.mutation SendTestBroadcast { createBroadcast(input: { message: ""Hello from RedwoodJS + Infobip!"", recipients: [""+14155550100"", ""+14155550101""] # Use valid E.164 numbers }) { id message recipients status infobipBulkId errorMessage createdAt } }
-
Check the Response: Observe the response in the Playground. If successful, you should see the created broadcast details with
status: ""SENT""
and aninfobipBulkId
. If there's an error (e.g., bad API key, invalid number), theerrors
array will contain details, and the DB record should showstatus: ""FAILED""
. Check the terminal runningyarn rw dev
for detailed logs from the API service. -
Check Infobip Portal: Log in to Infobip and check the SMS logs or analytics section to confirm the messages were processed (or see specific errors there).
4. Integrating with Infobip (Covered in Service Implementation)
The core integration happens within the createBroadcast
service function (api/src/services/broadcasts/broadcasts.ts
), detailed in Section 2, Step 4.
Key Integration Points:
- API Endpoint:
POST {INFOBIP_BASE_URL}/sms/2/text/advanced
- Authentication:
Authorization: App {INFOBIP_API_KEY}
header. - Request Body: JSON payload containing
messages
array, each withdestinations
(array of{ to: number }
) andtext
. Optionally includesfrom
(sender ID). - Credentials: Securely loaded from
.env
usingprocess.env
. - Error Handling: Parsing Infobip's success and error responses.
Security:
- API Keys: Never commit API keys directly into code. Use environment variables (
.env
locally, platform's secrets management in production). The.gitignore
file included with RedwoodJS correctly ignores.env
. - No Client-Side Exposure: Infobip credentials are only accessed on the API side, never exposed to the browser.
Fallback Mechanisms: (Advanced)
For critical broadcasts, consider:
- Retries: Implement exponential backoff retries within the service if the initial Infobip request fails due to transient network issues or temporary Infobip unavailability (using libraries like
async-retry
). Be cautious not to retry on definitive failures (like invalid credentials). - Alternative Provider: In high-availability scenarios, you might have a secondary SMS provider configured, switching if Infobip returns persistent errors. This adds significant complexity.
5. Implementing Error Handling, Logging, and Retries
-
Error Handling Strategy:
- Use
try...catch
blocks for database operations and API calls. - Throw specific, user-friendly errors from the service (caught by GraphQL and returned to the client).
- Update the
Broadcast
record status (FAILED
) and storeerrorMessage
in the database upon failure.
- Use
-
Logging:
- RedwoodJS has a built-in Pino logger (
api/src/lib/logger.js
). - Use
logger.info()
,logger.warn()
,logger.error()
within the service to log key events, input data (carefully, avoid logging sensitive info like full recipient lists if privacy is paramount), API responses, and errors. - Logs appear in the console running
yarn rw dev
. In production, configure log destinations (e.g., stdout for platform logging services, or specific transports like Datadog/Sentry). - Example Log Analysis: If a broadcast fails, check the logs for entries around the time of the request. Look for
Infobip API request failed
errors, the status code, and the response body provided by Infobip. Also check for database errors (Failed to create broadcast record
,Failed to update broadcast status
).
- RedwoodJS has a built-in Pino logger (
-
Retry Mechanisms: (Basic Example - see Service code for placement) A simple retry loop could be added, but for robust retries, a library is better.
// Inside createBroadcast, around the fetch call (Conceptual) const MAX_RETRIES = 3; let attempt = 0; let response; let responseBody; while (attempt < MAX_RETRIES) { try { response = await fetch(/*...*/); // The fetch call responseBody = await response.json(); if (response.ok) { // Success - break loop break; } else if (response.status >= 500 && attempt < MAX_RETRIES - 1) { // Server error_ retryable attempt++; logger.warn(`Infobip request failed (attempt ${attempt}/${MAX_RETRIES}). Retrying...`); await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt))); // Exponential backoff continue; // Retry the loop } else { // Client error (4xx) or max retries reached - throw specific error logger.error(/*...*/) // Log the final failure // ... update DB to FAILED ... throw new Error(/*...*/) // Throw based on responseBody } } catch (networkError) { if (attempt < MAX_RETRIES - 1) { attempt++; logger.warn(`Network error during Infobip request (attempt ${attempt}/${MAX_RETRIES}). Retrying...`_ networkError); await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt))); continue; // Retry } else { logger.error(/*...*/) // Log final network failure // ... update DB to FAILED ... throw networkError; // Re-throw after max retries } } } // ... process successful response or handle final failure outside the loop ...
This is a simplified example. Libraries like
async-retry
handle this more elegantly.
6. Creating the Database Schema and Data Layer (Covered in Section 2)
- Schema Definition: The
Broadcast
model was defined inapi/db/schema.prisma
. - Migrations: Managed using
yarn rw prisma migrate dev
. Prisma tracks applied migrations. - Data Access: Redwood services use the Prisma Client (
db
imported fromsrc/lib/db
) for type-safe database operations (db.broadcast.create
,db.broadcast.findMany
,db.broadcast.update
, etc.). - Optimization: For this use case, indexing on
status
orcreatedAt
might be useful if querying large numbers of broadcasts frequently. Prisma automatically handles connection pooling.
7. Adding Security Features
- Input Validation: Implemented in the service (
createBroadcast
) to check for empty messages/recipients and valid E.164 phone number formats. Sanitize inputs further if necessary (e.g., stripping unexpected characters, although Prisma helps prevent SQL injection). - Authentication/Authorization: Use Redwood's built-in auth (
@requireAuth
in SDL) to ensure only logged-in users can trigger broadcasts. Implement role-based access control if different user types have different permissions. - Rate Limiting: Crucial for public-facing APIs or to prevent abuse. Implement rate limiting middleware (e.g., using
graphql-shield
or custom logic in your service/GraphQL setup) to limit requests per user/IP. Infobip also enforces its own API rate limits. - CSRF Protection: RedwoodJS has built-in CSRF protection for form submissions handled via its framework components. Ensure standard security headers (like those set by Redwood's default configuration) are in place.
- Common Vulnerabilities: Redwood's structure (Prisma for DB access, GraphQL layer) helps mitigate common web vulnerabilities like SQL Injection and Mass Assignment when used correctly. Always validate and sanitize user input.
8. Handling Special Cases
- Phone Number Formatting: Strictly enforce E.164 format (
+
followed by country code and number, no spaces or dashes) as required by Infobip. The validation function provides a basic check. - Large Recipient Lists: Infobip's bulk endpoint (
/sms/2/text/advanced
) is designed for multiple destinations in a single API call. However, extremely large lists (e.g., >10,000) might hit request size limits or timeouts. Consider:- Chunking: Split the recipient list into smaller batches (e.g., 1000 per batch) and send multiple requests sequentially or in parallel (carefully, respecting rate limits). You'd need to manage the status across multiple
infobipBulkId
s. - Infobip Documentation: Check Infobip's specific limits for the bulk endpoint.
- Chunking: Split the recipient list into smaller batches (e.g., 1000 per batch) and send multiple requests sequentially or in parallel (carefully, respecting rate limits). You'd need to manage the status across multiple
- Character Limits & Encoding: Standard SMS messages have character limits (160 for GSM-7, 70 for UCS-2). Infobip handles splitting longer messages into multiple parts automatically (concatenated SMS), but this affects cost. Be mindful of message length in the UI. Special characters might force UCS-2 encoding, reducing the limit per part. Infobip's API can sometimes preview message encoding/parts.
- Sender ID Regulations: Sender ID rules (alphanumeric vs. numeric, pre-registration requirements) vary significantly by country. Ensure your chosen
INFOBIP_SENDER_ID
is valid and approved for your target countries via the Infobip portal. Sending might fail otherwise. - Delivery Reports: (Advanced) This guide focuses on sending. To get detailed delivery status (Delivered, Undelivered, Failed) for each recipient, you need to:
- Configure a webhook URL in Infobip to receive delivery reports.
- Create a new RedwoodJS function (e.g.,
api/src/functions/infobipWebhook.ts
) to handle incoming POST requests from Infobip. - Parse the delivery report payload.
- Update the status of individual messages/recipients (requires a more granular data model, perhaps linking
RecipientStatus
toBroadcast
).
9. Building the User Interface
Now, let's wire up the frontend components generated earlier.
-
Broadcast Form Component: Implement the form to capture recipients and the message.
// web/src/components/BroadcastForm/BroadcastForm.tsx import { Form, Label, TextAreaField, Submit, FieldError, FormError, } from '@redwoodjs/forms' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { gql } from '@redwoodjs/web' // Import gql import { QUERY as BroadcastsQuery } from 'src/components/BroadcastsCell' // Import the cell query // Define mutation in GraphQL syntax const CREATE_BROADCAST_MUTATION = gql` mutation CreateBroadcastMutation($input: CreateBroadcastInput!) { createBroadcast(input: $input) { id # Request needed fields back } } ` interface BroadcastFormProps { onSuccess?: () => void // Optional callback after successful submission } const BroadcastForm = ({ onSuccess }: BroadcastFormProps) => { const [createBroadcast, { loading, error }] = useMutation( CREATE_BROADCAST_MUTATION, { onCompleted: () => { toast.success('Broadcast sent successfully!') onSuccess?.() // Call the callback if provided }, onError: (error) => { toast.error(`Error sending broadcast: ${error.message}`) }, // Refetch the list of broadcasts after creating a new one refetchQueries: [{ query: BroadcastsQuery }], awaitRefetchQueries: true, // Ensure refetch completes before onCompleted fires fully } ) const onSubmit = (data) => { // Convert comma/newline separated recipients string into an array const recipientsArray = data.recipients .split(/[\n,]+/) // Split by newline or comma .map((r: string) => r.trim()) // Trim whitespace .filter((r: string) => r !== '') // Remove empty entries if (recipientsArray.length === 0) { toast.error('Please enter at least one recipient phone number.'); return; } const input = { message: data.message, recipients: recipientsArray } console.log('Submitting broadcast:', input) createBroadcast({ variables: { input } }) } return ( <div className=""rw-form-wrapper""> <Form onSubmit={onSubmit} error={error}> <FormError error={error} wrapperClassName=""rw-form-error-wrapper"" titleClassName=""rw-form-error-title"" listClassName=""rw-form-error-list"" /> <Label name=""recipients"" className=""rw-label"" errorClassName=""rw-label rw-label-error""> Recipients (E.164 format, one per line or comma-separated) </Label> <TextAreaField name=""recipients"" className=""rw-input"" errorClassName=""rw-input rw-input-error"" placeholder=""+14155550100_+14155550101"" validation={{ required: true }} rows={5} /> <FieldError name=""recipients"" className=""rw-field-error"" /> <Label name=""message"" className=""rw-label"" errorClassName=""rw-label rw-label-error""> Message </Label> <TextAreaField name=""message"" className=""rw-input"" errorClassName=""rw-input rw-input-error"" validation={{ required: true }} rows={3} // Example rows attribute /> <FieldError name=""message"" className=""rw-field-error"" /> <div className=""rw-button-group""> <Submit disabled={loading} className=""rw-button rw-button-blue""> {loading ? 'Sending...' : 'Send Broadcast'} </Submit> </div> </Form> </div> ) } export default BroadcastForm