This guide provides a step-by-step walkthrough for building a RedwoodJS application capable of scheduling and sending SMS reminders using the Sinch SMS API. We'll create a simple appointment reminder system where users can input appointment details, and the application will automatically schedule an SMS reminder to be sent via Sinch at a specified time before the appointment.
This solves the common need for automated, time-sensitive notifications without requiring complex background job infrastructure within the application itself, leveraging Sinch's built-in scheduling capabilities.
Key Technologies:
RedwoodJS: A full-stack, serverless-friendly JavaScript/TypeScript framework for the web. Chosen for its integrated frontend (React) and backend (GraphQL API, Prisma), developer experience, and conventions.
Sinch SMS API: A service for sending and receiving SMS messages globally. We'll use its Node.js SDK and specifically its send_at feature for scheduling.
Prisma: A next-generation ORM for Node.js and TypeScript, used by RedwoodJS for database access.
Node.js: The underlying JavaScript runtime environment.
PostgreSQL (or SQLite): The database for storing appointment information.
System Architecture:
The system follows this general data flow:
The user interacts with the RedwoodJS Frontend (React) in their browser.
The Frontend sends requests to the RedwoodJS API (GraphQL).
The API routes requests to the Redwood Reminder Service.
The Reminder Service uses the Prisma ORM to store and retrieve data from the Database (PostgreSQL/SQLite).
The Reminder Service also makes API calls to the Sinch SMS API to schedule messages.
At the scheduled time, the Sinch SMS API sends the SMS to the User's Phone.
Prerequisites:
Node.js (v20 or higher recommended – check RedwoodJS docs for current requirements)
Yarn v1 (v1.22.x or higher recommended; RedwoodJS currently relies on Yarn v1 features)
A Sinch Account: You'll need API credentials (Project ID, Key ID, Key Secret) and a provisioned phone number.
Access to a terminal or command prompt.
Basic understanding of JavaScript, React, GraphQL, and databases.
Final Outcome:
A RedwoodJS application with a simple UI to schedule appointment reminders. The backend will validate input, store appointment details, and use the Sinch Node SDK to schedule an SMS to be sent 2 hours before the scheduled appointment time.
1. Setting up the RedwoodJS Project
Let's initialize a new RedwoodJS project and configure the necessary environment.
Create Redwood App:
Open your terminal and navigate to the directory where you want to create your project. Run the following command, replacing <your-app-name> with your desired project name (e.g., redwood-sinch-reminders):
We use --typescript for enhanced type safety, which is recommended for production applications.
Follow the prompts:
Initialize git repo? yes (recommended)
Enter commit message: Initial commit (or your preferred message)
Run yarn install? yes
Navigate to Project Directory:
bash
cd<your-app-name>
Install Additional Dependencies:
We need the Sinch SDK and luxon for robust date/time manipulation.
bash
yarn workspace api add @sinch/sdk-core luxon
yarn workspace api add-D @types/luxon # Dev dependency for types
@sinch/sdk-core: The official Sinch Node.js SDK.
luxon: A powerful library for handling dates, times, and time zones.
Environment Variable Setup:
Redwood uses .env files for environment variables. The Sinch SDK requires credentials. Create a .env file in the root of your project:
bash
touch .env
Add the following variables to your .env file, replacing the placeholder values with your actual Sinch credentials and configuration:
bash
# .env# Find these in your Sinch Dashboard. Navigate to API Credentials# under your Project Settings (often via Access Keys section).SINCH_KEY_ID='YOUR_SINCH_KEY_ID'SINCH_KEY_SECRET='YOUR_SINCH_KEY_SECRET'SINCH_PROJECT_ID='YOUR_SINCH_PROJECT_ID'# Obtain from your Sinch Customer Dashboard -> Numbers -> Your Numbers# Ensure the number is SMS enabled and assigned to the correct Project IDSINCH_FROM_NUMBER='+1xxxxxxxxxx'# Use E.164 format# Specify the Sinch API region (e.g., 'us' or 'eu')# This is crucial for directing API calls to the correct Sinch datacenter.# Check Sinch documentation for available regions.SINCH_SMS_REGION='us'# Default country code prefix for numbers if not provided in E.164 format# Adjust based on your primary target region if needed, but prefer E.164 inputDEFAULT_COUNTRY_CODE='+1'
SINCH_KEY_ID, SINCH_KEY_SECRET, SINCH_PROJECT_ID: Find these in your Sinch Dashboard under your project's API credentials/Access Keys section. Treat SINCH_KEY_SECRET like a password – never commit it to Git.
SINCH_FROM_NUMBER: A virtual number you've acquired through Sinch, enabled for SMS, and associated with your Project ID. Must be in E.164 format (e.g., +12025550187).
SINCH_SMS_REGION: The regional endpoint for the Sinch API (e.g., us, eu). Use the region closest to your user base or where your account is homed. This is essential for the SDK to connect to the correct API endpoint.
DEFAULT_COUNTRY_CODE: Used as a fallback if the user enters a local number format. It's best practice to require E.164 format input.
Add .env to .gitignore:
Ensure your .env file (containing secrets) is not committed to version control. Open your project's root .gitignore file and add .env if it's not already present.
# .gitignore
# ... other entries
.env
.env.defaults # Often safe to commit, but double-check
.env.development
.env.production
# ...
Initial Commit (if not done during creation):
If you didn't initialize Git during create redwood-app:
bash
git init
gitadd.git commit -m"Initial project setup with dependencies and env structure"
2. Creating the Database Schema and Data Layer
We need a database table to store the details of the scheduled reminders.
Define Prisma Schema:
Open api/db/schema.prisma and define a model for Reminder:
sql
// api/db/schema.prismadatasource db {
provider =""postgresql""// Or ""sqlite"" for local dev/simplicity url = env(""DATABASE_URL"")}
generator client {
provider =""prisma-client-js"" binaryTargets =""native""}
model Reminder {
id Int@id@default(autoincrement()) patientName String
doctorName String
phoneNumber String // Store in E.164 format (e.g., +1xxxxxxxxxx) appointmentTime DateTime// Store in UTC reminderTime DateTime// Store in UTC (when the SMS should be sent)status String @default(""PENDING"")// PENDING, SENT, FAILED sinchBatchId String? // Store the ID returned by Sinch API createdAt DateTime@default(now()) updatedAt DateTime@updatedAt @@index([status, reminderTime])// Index for potential future querying/cleanup}
We store phone numbers in E.164 format for consistency.
All DateTime fields should ideally be stored in UTC to avoid time zone issues.
status tracks the state of the reminder (PENDING means scheduled but not yet sent).
sinchBatchId can be useful for tracking the message status within Sinch later.
An index on status and reminderTime could be useful for querying pending jobs or cleanup tasks.
Set up Database Connection:
Ensure your DATABASE_URL in the .env file points to your database (e.g., postgresql://user:password@host:port/database). For local development, Redwood defaults to SQLite, which requires no extra setup if you stick with the default provider = ""sqlite"".
Run Database Migration:
Apply the schema changes to your database:
bash
yarn rw prisma migrate dev
This command creates a new SQL migration file based on your schema.prisma changes and applies it to your development database. Provide a name for the migration when prompted (e.g., create reminder model).
Implement the scheduleReminder Service Function:
Open api/src/services/reminders/reminders.ts and add the logic to create a reminder record and schedule the SMS via Sinch.
typescript
// api/src/services/reminders/reminders.tsimport{ validate }from'@redwoodjs/api'importtype{ MutationResolvers }from'types/graphql'import{ db }from'src/lib/db'import{ logger }from'src/lib/logger'import{ sinchClient }from'src/lib/sinch'// We'll create this client lib nextimport{ DateTime }from'luxon'interfaceScheduleReminderInput{ patientName:string doctorName:string phoneNumber:string// Expecting E.164 format appointmentDate:string// e.g., '2025-07-15' appointmentTime:string// e.g., '14:30' timeZone:string// e.g., 'America/New_York'}// --- Helper Function for Phone Number Validation/Normalization ---const normalizePhoneNumber =(inputPhone:string):string=>{// Basic check for E.164 format (starts with +, followed by digits)if(/^\+[1-9]\d{1,14}$/.test(inputPhone)){return inputPhone
}// Attempt to prefix with default country code if it looks like a local number// WARNING: This is a significant simplification. Real-world validation is complex.// Using a library like libphonenumber-js is highly recommended for production// as it handles varying international and local formats, plus provides// robust validation, which this basic example lacks.const digits = inputPhone.replace(/\D/g,'')if(digits.length >=10){// Basic check for common lengthsreturn(process.env.DEFAULT_COUNTRY_CODE||'+1')+ digits
}thrownewError('Invalid phone number format. Please use E.164 format (e.g., +1xxxxxxxxxx).')}// --- Main Service Function ---exportconst scheduleReminder: MutationResolvers['scheduleReminder']=async({ input,}:{ input: ScheduleReminderInput
})=>{ logger.info({ input },'Received request to schedule reminder')// 1. Validate Inputvalidate(input.patientName,'Patient Name',{ presence:true, length:{ min:1}})validate(input.doctorName,'Doctor Name',{ presence:true, length:{ min:1}})validate(input.phoneNumber,'Phone Number',{ presence:true})validate(input.appointmentDate,'Appointment Date',{ presence:true})validate(input.appointmentTime,'Appointment Time',{ presence:true})validate(input.timeZone,'Time Zone',{ presence:true})// Ensure timezone is providedlet normalizedPhone:string;try{ normalizedPhone =normalizePhoneNumber(input.phoneNumber)}catch(error){ logger.error({ error },'Phone number validation failed')thrownewError(error.message)}// 2. Calculate Appointment and Reminder Times using Luxonlet appointmentDateTime: DateTime;let reminderDateTime: DateTime;try{const dateTimeString =`${input.appointmentDate}T${input.appointmentTime}` appointmentDateTime = DateTime.fromISO(dateTimeString,{ zone: input.timeZone })if(!appointmentDateTime.isValid){thrownewError(`Invalid date/time format or timezone: ${appointmentDateTime.invalidReason}`)}// Calculate reminder time (e.g., 2 hours before appointment) reminderDateTime = appointmentDateTime.minus({ hours:2})// Validation: Ensure reminder time is in the futureif(reminderDateTime <= DateTime.now()){thrownewError('Calculated reminder time is in the past. Appointment must be sufficiently in the future.')}}catch(error){ logger.error({ error_ input }_ 'Error processing date/time')thrownewError(`Failed to process date/time: ${error.message}`)}// Convert times to UTC for storage and Sinch APIconst appointmentTimeUtc = appointmentDateTime.toUTC()const reminderTimeUtc = reminderDateTime.toUTC()// Format for Sinch API (ISO 8601 with Z for UTC)const sendAtIso = reminderTimeUtc.toISO()// Luxon defaults to ISO 8601// 3. Construct SMS Message Bodyconst messageBody =`Hi ${input.patientName}_ this is a reminder for your appointment with Dr. ${input.doctorName} on ${appointmentDateTime.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY)} at ${appointmentDateTime.toLocaleString(DateTime.TIME_SIMPLE)}. Reply STOP to unsubscribe.`// Added opt-out language logger.info({ normalizedPhone_
messageBody_
sendAtIso_
appointmentTime: appointmentDateTime.toISO()_
timeZone: input.timeZone
}_ 'Prepared reminder details')// 4. Schedule SMS with Sinch APIlet sinchResponse;try{ sinchResponse =await sinchClient.sms.batches.send({ sendSMSRequestBody:{ to:[normalizedPhone]_
from: process.env.SINCH_FROM_NUMBER_ body: messageBody_
send_at: sendAtIso_ // Use the calculated UTC time// Optional: Add delivery_report: 'full' or 'summary' if needed}_
}) logger.info({ sinchResponse }_ 'Successfully scheduled SMS via Sinch')}catch(error){ logger.error({ error }_ 'Failed to schedule SMS via Sinch API')// Consider creating the DB record with status 'FAILED' or throwingthrownewError(`Failed to schedule SMS: ${error.response?.data?.error?.message || error.message}`)}// 5. Store Reminder in Databasetry{const createdReminder =await db.reminder.create({ data:{ patientName: input.patientName_
doctorName: input.doctorName_
phoneNumber: normalizedPhone_
appointmentTime: appointmentTimeUtc.toJSDate()_ // Convert Luxon DateTime to JS Date for Prisma reminderTime: reminderTimeUtc.toJSDate()_
status:'PENDING'_ // Indicates scheduled but not yet sent sinchBatchId: sinchResponse?.id_ // Store the batch ID if available}_
}) logger.info({ reminderId: createdReminder.id }_ 'Reminder record created in database')return createdReminder
}catch(dbError){ logger.error({ dbError_ sinchBatchId: sinchResponse?.id }_ 'Failed to save reminder to database after scheduling SMS')// Critical failure: SMS scheduled_ but DB save failed.// Production systems need robust compensation logic: e.g._ save reminder// with 'SCHEDULING_FAILED_DB' status *before* API call_ update to// 'PENDING' after success_ or have a cleanup job identify orphaned scheduled// messages via the Sinch Batch ID. Log details for manual intervention.// Consider attempting to cancel the Sinch message if the API supports it.thrownewError('Failed to save reminder details after scheduling. Manual check required.')}}
Input Validation: Uses Redwood's validate and custom logic for the phone number and date/time. The phone number normalization explicitly notes its limitations and recommends libphonenumber-js for production.
Date/Time Handling: Leverages luxon to parse the input date/time with the provided time zone_ calculate the reminder time_ and convert both to UTC for storage and the API call. Crucially validates that the reminder time is in the future.
Sinch Client: Calls the sinchClient.sms.batches.send method (we'll define sinchClient next).
send_at: Passes the calculated reminderTimeUtc in ISO 8601 format to Sinch's send_at parameter.
Database Storage: Saves the reminder details with status 'PENDING' and the sinchBatchId.
Error Handling: Includes try...catch blocks. The database error handling after a successful Sinch call has been enhanced with stronger warnings and suggestions for compensation logic.
Create Sinch Client Library:
It's good practice to centralize the Sinch SDK client initialization.
Create a new file: api/src/lib/sinch.ts
typescript
// api/src/lib/sinch.tsimport{ SinchClient }from'@sinch/sdk-core'import{ logger }from'./logger'// Ensure required environment variables are presentconst requiredEnvVars:string[]=['SINCH_PROJECT_ID'_
'SINCH_KEY_ID'_
'SINCH_KEY_SECRET'_
'SINCH_SMS_REGION'_ // Crucial for API endpoint routing'SINCH_FROM_NUMBER'_ // Needed for sending]for(const envVar of requiredEnvVars){if(!process.env[envVar]){const errorMessage =`Configuration error: Missing required Sinch environment variable ${envVar}` logger.fatal(errorMessage)// Throwing here will prevent the app from starting without essential configthrownewError(errorMessage)}}// Initialize the Sinch Client// The SDK should ideally use the provided credentials and potentially region// information to route requests correctly.exportconst sinchClient =newSinchClient({ projectId: process.env.SINCH_PROJECT_ID_ keyId: process.env.SINCH_KEY_ID_ keySecret: process.env.SINCH_KEY_SECRET_// The @sinch/sdk-core might use the region in different ways depending on the specific API (SMS_ Voice etc.)// Ensure SINCH_SMS_REGION is set correctly in your environment.// Consult the latest Sinch Node.js SDK documentation for the precise method// of ensuring the SMS API calls target the correct region if issues arise.// It might involve setting a base URL or a specific regional property.})// Log confirmation that the client is initialized (without exposing secrets)logger.info('Sinch Client Initialized with Project ID and Key ID.')// Although the region variable is checked above_ re-emphasize its importance.logger.info(`Sinch client configured to target region: ${process.env.SINCH_SMS_REGION}`)
This initializes the SinchClient using the environment variables.
Includes strict checks to ensure necessary variables are set_ preventing runtime errors due to missing configuration.
Exports the initialized client for use in services.
Clarifies the importance of the SINCH_SMS_REGION environment variable for correct API endpoint targeting and advises checking Sinch SDK docs for the specific mechanism if needed.
4. Building the API Layer (GraphQL)
Define the GraphQL mutation to expose the scheduleReminder service function.
Define GraphQL Schema:
Open api/src/graphql/reminders.sdl.ts. Redwood generators created a basic structure. Modify it to define the ScheduleReminderInput and the scheduleReminder mutation.
graphql
# api/src/graphql/reminders.sdl.tsinputScheduleReminderInput{patientName:String!doctorName:String!phoneNumber:String!# E.164 format preferred (e.g._ +1xxxxxxxxxx)appointmentDate:String!# Format: YYYY-MM-DDappointmentTime:String!# Format: HH:MM (24-hour)timeZone:String!# IANA Time Zone Name (e.g._ America/New_York)}typeReminder{id:Int!patientName:String!doctorName:String!phoneNumber:String!appointmentTime:DateTime!reminderTime:DateTime!status:String!sinchBatchId:StringcreatedAt:DateTime!updatedAt:DateTime!}typeMutation{""""""SchedulesanewSMSreminderviaSinch.""""""scheduleReminder(input:ScheduleReminderInput!):Reminder# @requireAuth removed for initial dev# Add other mutations like cancelReminder_ updateReminder if needed# Example: deleteReminder(id: Int!): Reminder @requireAuth}# We don't define Query type here unless needed for listing reminders etc.# type Query {# reminders: [Reminder!]! @requireAuth# reminder(id: Int!): Reminder @requireAuth# }
Defines the input structure expected by the mutation. Using ! marks fields as required.
Defines the Reminder type that mirrors our Prisma model and will be returned by the mutation on success.
Defines the scheduleReminder mutation_ taking the input and returning a Reminder.
@requireAuth: Initially added by the generator. It has been removed here for easier initial testing. Important: Removing authentication is only for initial development convenience. You MUST re-enable or implement proper authentication before deploying to any non-local environment.
Testing the API Endpoint:
Once the development server is running (yarn rw dev)_ you can test the mutation using the Redwood GraphQL Playground (usually at http://localhost:8911/graphql) or a tool like curl or Postman.
GraphQL Playground Mutation:
graphql
mutationScheduleNewReminder{scheduleReminder(input:{patientName:""AliceWonderland""doctorName:""Cheshire""phoneNumber:""+15551234567""# Use a real test number if possibleappointmentDate:""2025-08-22""# Ensure this date/time is far enough in the futureappointmentTime:""15:00""timeZone:""America/Los_Angeles""# Use a valid IANA timezone}){idpatientNamephoneNumberappointmentTimereminderTimestatussinchBatchId}}
Replace placeholders with valid data. Ensure the appointmentDate and appointmentTime result in a reminderTime (2 hours prior) that is in the future from when you run the test.
Check the response in the GraphQL playground or terminal. You should see the details of the created Reminder record.
Check the API server logs (yarn rw dev output) for logs from the service function.
Check your Sinch Dashboard (Logs or specific API logs) to see if the message scheduling request was received.
5. Implementing the Frontend UI (Basic Form)
Let's create a simple React page with a form to schedule reminders.
Generate Page:
Create a new page component for the reminder form.
bash
yarn rw g page ReminderScheduler /schedule
This creates web/src/pages/ReminderSchedulerPage/ReminderSchedulerPage.tsx.
Build the Form:
Open web/src/pages/ReminderSchedulerPage/ReminderSchedulerPage.tsx and implement the form using Redwood Form components.
typescript
// web/src/pages/ReminderSchedulerPage/ReminderSchedulerPage.tsximport{ MetaTags_ useMutation }from'@redwoodjs/web'import{ toast_ Toaster }from'@redwoodjs/web/toast'import{ Form_
Label_
TextField_
DateField_
TimeField_
SelectField_
Submit_
FieldError_
FormError // Import FormError}from'@redwoodjs/forms'import{ useEffect_ useState }from'react'// GraphQL Mutation Definition (should match api/src/graphql/reminders.sdl.ts)constSCHEDULE_REMINDER_MUTATION= gql` mutation ScheduleReminder($input: ScheduleReminderInput!) {
scheduleReminder(input: $input) {
id # Request necessary fields back
}
}
`// Basic list of IANA time zones (add more as needed or use a library)const timeZones =['America/New_York'_ 'America/Chicago'_ 'America/Denver'_ 'America/Los_Angeles'_
'America/Anchorage'_ 'America/Honolulu'_ 'Europe/London'_ 'Europe/Paris'_
'Asia/Tokyo'_ 'Australia/Sydney'_ 'UTC'// Add more relevant time zones here];const ReminderSchedulerPage =()=>{const[createReminder,{ loading, error }]=useMutation(SCHEDULE_REMINDER_MUTATION,{ onCompleted:()=>{ toast.success('Reminder scheduled successfully!')// Optionally reset form here}, onError:(error)=>{ toast.error(`Error scheduling reminder: ${error.message}`)console.error(error)},})// Get user's local timezone guess (optional, provide a default)const[defaultTimeZone, setDefaultTimeZone]=useState('UTC');useEffect(()=>{try{setDefaultTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);}catch(e){console.warn("Could not detect browser timezone.")}},[])const onSubmit =(data)=>{console.log('Submitting data:', data)// The service expects separate date/time strings, so direct submission is okaycreateReminder({ variables:{ input: data }})}return(<><MetaTags title="Schedule Reminder" description="Schedule an SMS reminder"/><Toaster toastOptions={{ className:'rw-toast'_ duration:6000}}/><h1>Schedule New Reminder</h1><Form onSubmit={onSubmit} error={error} className="rw-form-wrapper">{/* Display top-level form errors (like network issues) */}<FormError error={error} wrapperClassName="rw-form-error-wrapper" titleClassName="rw-form-error-title" listClassName="rw-form-error-list"/><Label name="patientName" errorClassName="rw-label rw-label-error">Patient Name</Label><TextField
name="patientName" validation={{ required:true}} errorClassName="rw-input rw-input-error" className="rw-input"/><FieldError name="patientName" className="rw-field-error"/><Label name="doctorName" errorClassName="rw-label rw-label-error">Doctor Name</Label><TextField
name="doctorName" validation={{ required:true}} errorClassName="rw-input rw-input-error" className="rw-input"/><FieldError name="doctorName" className="rw-field-error"/><Label name="phoneNumber" errorClassName="rw-label rw-label-error">Phone Number(E.164 Format:+1xxxxxxxxxx)</Label><TextField
name="phoneNumber" placeholder="+15551234567" validation={{ required: true_
pattern:{ value:/^\+[1-9]\d{1_14}$/_ // Basic E.164 regex message:'Please enter in E.164 format (e.g._ +15551234567)'_
}_
}} errorClassName="rw-input rw-input-error" className="rw-input"/><FieldError name="phoneNumber" className="rw-field-error"/><Label name="appointmentDate" errorClassName="rw-label rw-label-error">Appointment Date</Label><DateField
name="appointmentDate" validation={{ required:true}} errorClassName="rw-input rw-input-error" className="rw-input"/><FieldError name="appointmentDate" className="rw-field-error"/><Label name="appointmentTime" errorClassName="rw-label rw-label-error">Appointment Time(24-hour)</Label><TimeField
name="appointmentTime" validation={{ required:true}} errorClassName="rw-input rw-input-error" className="rw-input"/><FieldError name="appointmentTime" className="rw-field-error"/><Label name="timeZone" errorClassName="rw-label rw-label-error">Appointment Time Zone</Label><SelectField
name="timeZone" validation={{ required:true}} errorClassName="rw-input rw-input-error" className="rw-input" defaultValue={defaultTimeZone}// Set default based on browser guess>{timeZones.map(tz =>(<option key={tz} value={tz}>{tz}</option>))}</SelectField><FieldError name="timeZone" className="rw-field-error"/><div className="rw-button-group"><Submit disabled={loading} className="rw-button rw-button-blue">Schedule Reminder</Submit></div></Form></>)}exportdefault ReminderSchedulerPage
Uses Redwood's useMutation hook to call the scheduleReminder GraphQL mutation.
Uses Redwood Form components (<Form>, <TextField>, <DateField>, <TimeField>, <SelectField>, <Submit>, <FieldError>, <FormError>) for structure, validation, and error handling.
Includes basic client-side validation (required, pattern). More complex validation happens in the service.
Uses Toaster for displaying success/error messages.
Includes a <SelectField> for selecting the appointment's time zone, crucial for correct calculation. It attempts to default to the user's browser time zone.
Add Route:
Ensure the route is defined in web/src/Routes.tsx:
typescript
// web/src/Routes.tsximport{ Router, Route, Set }from'@redwoodjs/router'import GeneralLayout from'src/layouts/GeneralLayout/GeneralLayout'// Example layoutconst Routes =()=>{return(<Router><Set wrap={GeneralLayout}>// Use your desired layout<Route path="/schedule" page={ReminderSchedulerPage} name="scheduleReminder"/>{/* Add other routes here */}<Route notfound page={NotFoundPage}/></Set></Router>)}exportdefault Routes
Run and Test:
Start the development server:
bash
yarn rw dev
Navigate to http://localhost:8910/schedule (or your configured port). Fill out the form with valid data (ensure the appointment is far enough in the future) and submit. Check the browser console, API server logs, and Sinch dashboard for confirmation. You should receive the SMS 2 hours before the specified appointment time.
6. Error Handling, Logging, and Retries
Error Handling:
Frontend: Uses Redwood Forms FieldError and FormError, useMutation's onError callback, and toast notifications.
Backend (Service): Uses try...catch blocks around critical operations (validation, date/time parsing, API calls, database writes). Includes specific error messages and logs errors using Redwood's logger. Highlights the critical failure case where the SMS is scheduled but the database write fails, suggesting robust compensation logic for production.
Logging:
Uses Redwood's built-in logger on the API side (src/lib/logger.ts). Logs key events like receiving requests, preparing data, successful API calls, database writes, and errors. Avoid logging sensitive data like SINCH_KEY_SECRET.
Retries:
This basic implementation does not include automatic retries for failed Sinch API calls or database writes.
Production Considerations: Implement retry logic (e.g., using libraries like async-retry) for transient network errors when calling the Sinch API. For database write failures after successful scheduling, implement compensation logic (as mentioned in the service code comments) or a background job to reconcile states. Consider using a message queue for more robust job handling.
This guide provides a step-by-step walkthrough for building a RedwoodJS application capable of scheduling and sending SMS reminders using the Sinch SMS API. We'll create a simple appointment reminder system where users can input appointment details, and the application will automatically schedule an SMS reminder to be sent via Sinch at a specified time before the appointment.
This solves the common need for automated, time-sensitive notifications without requiring complex background job infrastructure within the application itself, leveraging Sinch's built-in scheduling capabilities.
Key Technologies:
send_at
feature for scheduling.System Architecture:
The system follows this general data flow:
Prerequisites:
Final Outcome:
A RedwoodJS application with a simple UI to schedule appointment reminders. The backend will validate input, store appointment details, and use the Sinch Node SDK to schedule an SMS to be sent 2 hours before the scheduled appointment time.
1. Setting up the RedwoodJS Project
Let's initialize a new RedwoodJS project and configure the necessary environment.
Create Redwood App: Open your terminal and navigate to the directory where you want to create your project. Run the following command, replacing
<your-app-name>
with your desired project name (e.g.,redwood-sinch-reminders
):--typescript
for enhanced type safety, which is recommended for production applications.yes
(recommended)Initial commit
(or your preferred message)yes
Navigate to Project Directory:
Install Additional Dependencies: We need the Sinch SDK and
luxon
for robust date/time manipulation.@sinch/sdk-core
: The official Sinch Node.js SDK.luxon
: A powerful library for handling dates, times, and time zones.Environment Variable Setup: Redwood uses
.env
files for environment variables. The Sinch SDK requires credentials. Create a.env
file in the root of your project:Add the following variables to your
.env
file, replacing the placeholder values with your actual Sinch credentials and configuration:SINCH_KEY_ID
,SINCH_KEY_SECRET
,SINCH_PROJECT_ID
: Find these in your Sinch Dashboard under your project's API credentials/Access Keys section. TreatSINCH_KEY_SECRET
like a password – never commit it to Git.SINCH_FROM_NUMBER
: A virtual number you've acquired through Sinch, enabled for SMS, and associated with your Project ID. Must be in E.164 format (e.g.,+12025550187
).SINCH_SMS_REGION
: The regional endpoint for the Sinch API (e.g.,us
,eu
). Use the region closest to your user base or where your account is homed. This is essential for the SDK to connect to the correct API endpoint.DEFAULT_COUNTRY_CODE
: Used as a fallback if the user enters a local number format. It's best practice to require E.164 format input.Add
.env
to.gitignore
: Ensure your.env
file (containing secrets) is not committed to version control. Open your project's root.gitignore
file and add.env
if it's not already present.Initial Commit (if not done during creation): If you didn't initialize Git during
create redwood-app
:2. Creating the Database Schema and Data Layer
We need a database table to store the details of the scheduled reminders.
Define Prisma Schema: Open
api/db/schema.prisma
and define a model forReminder
:DateTime
fields should ideally be stored in UTC to avoid time zone issues.status
tracks the state of the reminder (PENDING
means scheduled but not yet sent).sinchBatchId
can be useful for tracking the message status within Sinch later.status
andreminderTime
could be useful for querying pending jobs or cleanup tasks.Set up Database Connection: Ensure your
DATABASE_URL
in the.env
file points to your database (e.g.,postgresql://user:password@host:port/database
). For local development, Redwood defaults to SQLite, which requires no extra setup if you stick with the defaultprovider = ""sqlite""
.Run Database Migration: Apply the schema changes to your database:
schema.prisma
changes and applies it to your development database. Provide a name for the migration when prompted (e.g.,create reminder model
).3. Implementing Core Functionality (Reminder Service)
Now, let's create the RedwoodJS service that handles the logic for scheduling reminders.
Generate Service Files: Use the Redwood generator to create the necessary service and GraphQL files:
This creates:
api/src/services/reminders/reminders.ts
(Service logic)api/src/services/reminders/reminders.scenarios.ts
(Seed data for testing)api/src/services/reminders/reminders.test.ts
(Unit tests)api/src/graphql/reminders.sdl.ts
(GraphQL schema definition)Implement the
scheduleReminder
Service Function: Openapi/src/services/reminders/reminders.ts
and add the logic to create a reminder record and schedule the SMS via Sinch.validate
and custom logic for the phone number and date/time. The phone number normalization explicitly notes its limitations and recommendslibphonenumber-js
for production.luxon
to parse the input date/time with the provided time zone_ calculate the reminder time_ and convert both to UTC for storage and the API call. Crucially validates that the reminder time is in the future.sinchClient.sms.batches.send
method (we'll definesinchClient
next).send_at
: Passes the calculatedreminderTimeUtc
in ISO 8601 format to Sinch'ssend_at
parameter.'PENDING'
and thesinchBatchId
.try...catch
blocks. The database error handling after a successful Sinch call has been enhanced with stronger warnings and suggestions for compensation logic.Create Sinch Client Library: It's good practice to centralize the Sinch SDK client initialization.
Create a new file:
api/src/lib/sinch.ts
SinchClient
using the environment variables.SINCH_SMS_REGION
environment variable for correct API endpoint targeting and advises checking Sinch SDK docs for the specific mechanism if needed.4. Building the API Layer (GraphQL)
Define the GraphQL mutation to expose the
scheduleReminder
service function.Define GraphQL Schema: Open
api/src/graphql/reminders.sdl.ts
. Redwood generators created a basic structure. Modify it to define theScheduleReminderInput
and thescheduleReminder
mutation.!
marks fields as required.Reminder
type that mirrors our Prisma model and will be returned by the mutation on success.scheduleReminder
mutation_ taking the input and returning aReminder
.@requireAuth
: Initially added by the generator. It has been removed here for easier initial testing. Important: Removing authentication is only for initial development convenience. You MUST re-enable or implement proper authentication before deploying to any non-local environment.Testing the API Endpoint: Once the development server is running (
yarn rw dev
)_ you can test the mutation using the Redwood GraphQL Playground (usually athttp://localhost:8911/graphql
) or a tool likecurl
or Postman.GraphQL Playground Mutation:
Curl Example:
appointmentDate
andappointmentTime
result in areminderTime
(2 hours prior) that is in the future from when you run the test.Reminder
record.yarn rw dev
output) for logs from the service function.5. Implementing the Frontend UI (Basic Form)
Let's create a simple React page with a form to schedule reminders.
Generate Page: Create a new page component for the reminder form.
This creates
web/src/pages/ReminderSchedulerPage/ReminderSchedulerPage.tsx
.Build the Form: Open
web/src/pages/ReminderSchedulerPage/ReminderSchedulerPage.tsx
and implement the form using Redwood Form components.useMutation
hook to call thescheduleReminder
GraphQL mutation.<Form>
,<TextField>
,<DateField>
,<TimeField>
,<SelectField>
,<Submit>
,<FieldError>
,<FormError>
) for structure, validation, and error handling.required
,pattern
). More complex validation happens in the service.Toaster
for displaying success/error messages.<SelectField>
for selecting the appointment's time zone, crucial for correct calculation. It attempts to default to the user's browser time zone.Add Route: Ensure the route is defined in
web/src/Routes.tsx
:Run and Test: Start the development server:
Navigate to
http://localhost:8910/schedule
(or your configured port). Fill out the form with valid data (ensure the appointment is far enough in the future) and submit. Check the browser console, API server logs, and Sinch dashboard for confirmation. You should receive the SMS 2 hours before the specified appointment time.6. Error Handling, Logging, and Retries
FieldError
andFormError
,useMutation
'sonError
callback, andtoast
notifications.try...catch
blocks around critical operations (validation, date/time parsing, API calls, database writes). Includes specific error messages and logs errors using Redwood's logger. Highlights the critical failure case where the SMS is scheduled but the database write fails, suggesting robust compensation logic for production.logger
on the API side (src/lib/logger.ts
). Logs key events like receiving requests, preparing data, successful API calls, database writes, and errors. Avoid logging sensitive data likeSINCH_KEY_SECRET
.async-retry
) for transient network errors when calling the Sinch API. For database write failures after successful scheduling, implement compensation logic (as mentioned in the service code comments) or a background job to reconcile states. Consider using a message queue for more robust job handling.