Build Appointment Reminders with RedwoodJS and Twilio SMS
This guide provides a step-by-step walkthrough for building a web application using RedwoodJS that enables users to create appointments and automatically receive SMS reminders via Twilio's Programmable Messaging API. We will leverage Twilio's Message Scheduling feature for robust, reliable delivery without needing a custom scheduling system.
By the end of this tutorial, you will have a functional RedwoodJS application capable of:
- Creating, reading, updating, and deleting appointments.
- Automatically scheduling SMS reminders for appointments using Twilio.
- Handling time zones correctly for scheduling.
- Securely managing API credentials.
This guide assumes you have a basic understanding of JavaScript, Node.js, React, GraphQL, and database concepts.
Project Overview and Goals
Goal: Build a full-stack application where users can manage appointments, and the system automatically schedules and sends SMS reminders a configurable amount of time before the appointment.
Problem Solved: Automates the process of sending timely appointment reminders, reducing no-shows and improving communication. It eliminates the need for manual reminder processes or complex self-hosted scheduling infrastructure by using Twilio's native scheduling.
Technologies:
- RedwoodJS: A full-stack, serverless-first web application framework based on React, GraphQL, and Prisma. Chosen for its integrated structure, developer experience, and conventions that accelerate development.
- Twilio Programmable Messaging: Used for sending SMS reminders. Specifically, we'll use the Message Scheduling feature via the Twilio Node.js helper library. Chosen for its reliability, scalability, and developer-friendly API.
- Node.js: The runtime environment for RedwoodJS's API side.
- Prisma: The database toolkit used by RedwoodJS for database access and migrations.
- PostgreSQL (or SQLite/MySQL): The relational database to store appointment data.
System Architecture:
User Browser interacts with Redwood Frontend, which communicates via GraphQL with the Redwood API. The API uses Prisma to talk to the Database and makes calls to the Twilio API.
Prerequisites:
- Node.js (v18 or later recommended) and Yarn installed.
- A Twilio account (Sign up for free).
- An SMS-capable Twilio phone number purchased from your account.
- A Twilio Messaging Service configured (see Step 4).
- Access to a terminal or command prompt.
- A code editor (e.g., VS Code).
1. Setting up the RedwoodJS Project
Let's start by creating a new RedwoodJS application.
-
Create the RedwoodJS App: Open your terminal and run the following command, replacing
appointment-reminder-app
with your desired project name:yarn create redwood-app ./appointment-reminder-app
-
Navigate into the Project Directory:
cd appointment-reminder-app
-
Install Twilio Helper Library: Install the official Twilio Node.js helper library specifically in the API workspace:
yarn workspace api add twilio
-
Environment Variables: RedwoodJS uses a
.env
file for environment variables. Create this file in the project root:touch .env
Add the following variables to your
.env
file. We will obtain these values in Step 4 (Integrating with Twilio). Do not commit this file to version control if it contains sensitive credentials. Redwood's.gitignore
includes.env
by default.# .env TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWILIO_AUTH_TOKEN=your_auth_token_xxxxxxxxxxxxxx TWILIO_MESSAGING_SERVICE_SID=MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Database URL (Example for PostgreSQL - adjust if using SQLite/MySQL) # Replace user, password, host, port, dbname with your actual DB credentials DATABASE_URL="postgresql://user:password@host:port/dbname?schema=public"
TWILIO_ACCOUNT_SID
: Your primary Twilio account identifier.TWILIO_AUTH_TOKEN
: Your secret token for authenticating API requests. Treat this like a password.TWILIO_MESSAGING_SERVICE_SID
: The unique ID for the Twilio Messaging Service you'll configure. Scheduling requires a Messaging Service.DATABASE_URL
: The connection string for your database. Redwood defaults to SQLite for quick setup, but PostgreSQL is recommended for production. Update this connection string according to your database provider (e.g., Railway, Supabase, Heroku Postgres, local installation). For SQLite (default), the line would beDATABASE_URL="file:./dev.db"
.
2. Creating the Database Schema and Data Layer
We'll define the structure for storing appointment information using Prisma.
-
Define the Prisma Schema: Open
api/db/schema.prisma
and replace its contents with the followingAppointment
model:// api/db/schema.prisma datasource db { provider = ""postgresql"" // Or ""sqlite"", ""mysql"" url = env(""DATABASE_URL"") } generator client { provider = ""prisma-client-js"" binaryTargets = [""native""] } model Appointment { id Int @id @default(autoincrement()) name String phoneNumber String // Store in E.164 format (e.g., +15551234567) appointmentTime DateTime // Store in UTC timeZone String // Store the user's IANA time zone (e.g., 'America/New_York') reminderSent Boolean @default(false) // Flag if reminder is successfully scheduled scheduledMessageSid String? // Store Twilio's SID for cancellation/updates createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([appointmentTime]) // Add index for sorting/filtering by time }
appointmentTime
: We store the appointment time in UTC. This is crucial for consistent scheduling across time zones.timeZone
: Storing the original time zone allows us to format the time correctly for the user and potentially adjust reminder messages.scheduledMessageSid
: This optional field will hold the unique identifier (Sid
) of the scheduled message returned by Twilio. This is essential if you need to cancel or modify the reminder later.@@index([appointmentTime])
: Added an index to potentially improve query performance when sorting or filtering by appointment time.
-
Apply Migrations: Run the following command to create and apply the database migration. When prompted, give your migration a descriptive name (e.g.,
create_appointment_model
).yarn rw prisma migrate dev
This command does three things:
- Creates a new SQL migration file reflecting the schema changes.
- Applies the migration to your development database.
- Generates/updates the Prisma Client based on your schema.
3. Building the API Layer (GraphQL Services and SDL)
RedwoodJS uses GraphQL for its API. We'll generate the necessary SDL (Schema Definition Language) and service files to handle CRUD operations for appointments and interact with Twilio.
-
Generate SDL and Service: Use Redwood's generator to scaffold the GraphQL types, queries, mutations, and the service file for the
Appointment
model:yarn rw g sdl Appointment
This creates:
api/src/graphql/appointments.sdl.ts
: Defines the GraphQL schema (types, queries, mutations).api/src/services/appointments/appointments.ts
: Contains the business logic (resolvers) for the SDL definitions.api/src/services/appointments/appointments.scenarios.ts
: For defining seed data for testing.api/src/services/appointments/appointments.test.ts
: For writing unit tests.
-
Refine the GraphQL Schema: Open
api/src/graphql/appointments.sdl.ts
. Redwood generates basic CRUD operations. We need to adjust the input types slightly, particularly forappointmentTime
.// api/src/graphql/appointments.sdl.ts import gql from 'graphql-tag' // Ensure gql is imported if not auto-imported export const schema = gql` type Appointment { id: Int! name: String! phoneNumber: String! appointmentTime: DateTime! timeZone: String! reminderSent: Boolean! scheduledMessageSid: String createdAt: DateTime! updatedAt: DateTime! } type Query { appointments: [Appointment!]! @requireAuth appointment(id: Int!): Appointment @requireAuth } input CreateAppointmentInput { name: String! phoneNumber: String! # Expect E.164 format appointmentTime: DateTime! # Expect ISO 8601 String (UTC) timeZone: String! # Expect IANA Time Zone Name } input UpdateAppointmentInput { name: String phoneNumber: String appointmentTime: DateTime timeZone: String } type Mutation { createAppointment(input: CreateAppointmentInput!): Appointment! @requireAuth updateAppointment(id: Int!, input: UpdateAppointmentInput!): Appointment! @requireAuth deleteAppointment(id: Int!): Appointment! @requireAuth } `
- We explicitly expect
appointmentTime
as aDateTime
(which Apollo Server handles as an ISO 8601 string) and assume it's provided in UTC from the client. @requireAuth
is added for basic authorization (we'll touch on setup later). Remove it if you don't need authentication initially.
- We explicitly expect
-
Implement Service Logic (Including Twilio Integration): This is where the core logic resides. Open
api/src/services/appointments/appointments.ts
and implement the resolvers, including the Twilio scheduling logic within thecreateAppointment
andupdateAppointment
mutations.// api/src/services/appointments/appointments.ts import { Twilio } from 'twilio' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' // Redwood's built-in logger import type { QueryResolvers, MutationResolvers, AppointmentResolvers, Appointment as TAppointment, // Prisma model type CreateAppointmentInput, UpdateAppointmentInput, } from 'types/graphql' // Initialize Twilio Client (Best Practice: Do this once) // Ensure env vars are loaded (Redwood does this automatically) let twilioClient: Twilio | null = null; try { if (process.env.TWILIO_ACCOUNT_SID && process.env.TWILIO_AUTH_TOKEN) { twilioClient = new Twilio( process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN ); } else { logger.warn('Twilio credentials not found in environment variables. SMS scheduling will be disabled.'); } } catch (error) { logger.error({ error }, 'Failed to initialize Twilio client.'); twilioClient = null; // Ensure client is null if init fails } // --- Helper Function for Scheduling --- const scheduleReminder = async (appointment: { id: number // Can be 0 for new appointments before saving name: string phoneNumber: string appointmentTime: Date timeZone: string // For reminder message content, not scheduling time scheduledMessageSid?: string | null // Existing SID for updates/cancellations }) => { if (!twilioClient || !process.env.TWILIO_MESSAGING_SERVICE_SID) { logger.warn(`Twilio client not initialized or Messaging Service SID missing. Skipping reminder scheduling for appointment ${appointment.id}.`); return null; // Cannot schedule } // Reminder time: e.g., 1 hour before the appointment const reminderOffsetMinutes = 60 const sendAt = new Date( appointment.appointmentTime.getTime() - reminderOffsetMinutes * 60 * 1000 ) // Twilio requires schedule time > 15 mins (900s) and < 7 days (604800s) from now const now = new Date() const fifteenMinutesFromNow = new Date(now.getTime() + 15 * 60 * 1000) const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) if (sendAt <= fifteenMinutesFromNow) { logger.warn( `Appointment ${appointment.id} at ${appointment.appointmentTime.toISOString()} is too soon to schedule a reminder (calculated sendAt: ${sendAt.toISOString()}). Skipping.` ) // Optionally: Send immediately if desired and appropriate // Or handle as an error / provide user feedback return null // Indicate no message was scheduled } if (sendAt > sevenDaysFromNow) { logger.warn( `Appointment ${appointment.id} at ${appointment.appointmentTime.toISOString()} is too far in the future to schedule (calculated sendAt: ${sendAt.toISOString()}). Skipping.` ) // Consider background job for very distant appointments if needed return null // Indicate no message was scheduled } // --- Cancel existing reminder if updating --- if (appointment.scheduledMessageSid) { try { logger.info(`Cancelling existing reminder ${appointment.scheduledMessageSid} for appointment ${appointment.id}`) // Ensure client is available before attempting cancellation if (twilioClient) { await twilioClient.messages(appointment.scheduledMessageSid).update({ status: 'canceled' }); logger.info(`Successfully cancelled reminder ${appointment.scheduledMessageSid}`); } else { logger.warn(`Twilio client not available, cannot cancel reminder ${appointment.scheduledMessageSid}`); } } catch (error) { // Log error, but proceed to schedule new message. Possible errors: message already sent/canceled, SID invalid. // Avoid using `error.status === 404` check as it might indicate other issues. // Check Twilio docs for specific error codes if needed (e.g., 20404 usually means Not Found/already processed). logger.error({ error, appointmentId: appointment.id, sid: appointment.scheduledMessageSid }, `Failed to cancel previous reminder. It might have already been sent or canceled.`); } } // --- --- // Format appointment time for the SMS body using the user's time zone let localizedAppointmentTime = 'at the scheduled time'; // Fallback message try { localizedAppointmentTime = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', // e.g., Apr 20, 2025 timeStyle: 'short', // e.g., 10:30 AM timeZone: appointment.timeZone, // Use the stored time zone }).format(appointment.appointmentTime); } catch (e) { logger.error({ error: e, timeZone: appointment.timeZone }, 'Failed to format date with provided timezone for SMS body'); // Keep the fallback message } const messageBody = `Hi ${appointment.name}. Reminder: Your appointment is scheduled for ${localizedAppointmentTime} (${appointment.timeZone}).` try { logger.info( `Scheduling reminder for appointment ${appointment.id} to ${appointment.phoneNumber} at ${sendAt.toISOString()}` ) // Ensure client and SID are available before creating message if (!twilioClient || !process.env.TWILIO_MESSAGING_SERVICE_SID) { throw new Error('Twilio client or Messaging Service SID not available.'); } const message = await twilioClient.messages.create({ messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID, to: appointment.phoneNumber, // Ensure E.164 format from validation body: messageBody, scheduleType: 'fixed', sendAt: sendAt.toISOString(), // Send UTC ISO 8601 string }) logger.info( `Successfully scheduled message SID: ${message.sid} for appointment ${appointment.id}` ) return message.sid // Return the new message SID } catch (error) { logger.error( { error, appointmentId: appointment.id }, 'Failed to schedule Twilio message' ) // Re-throw or handle appropriately - maybe prevent appointment creation // or mark it as needing manual attention. throw new Error(`Failed to schedule SMS reminder: ${error.message || 'Unknown Twilio error'}`) } } // --- --- // Helper function for input validation const validateAppointmentInput = (input: CreateAppointmentInput | UpdateAppointmentInput, isUpdate = false) => { // Phone Number Validation (E.164 Required) if (input.phoneNumber && !/^\+[1-9]\d{1,14}$/.test(input.phoneNumber)) { throw new Error('Invalid phone number format. Use E.164 (e.g., +15551234567)'); } if (!isUpdate && !input.phoneNumber) { // Required for create throw new Error('Phone number is required.'); } // Time Zone Validation if (input.timeZone) { try { // Attempt to use the timezone; this will throw for fundamentally invalid strings Intl.DateTimeFormat(undefined, { timeZone: input.timeZone }); } catch (e) { logger.error({ error: e, timeZone: input.timeZone }, 'Invalid time zone format detected'); throw new Error(`Invalid or unrecognized time zone: ${input.timeZone}. Use IANA format (e.g., America/New_York)`); } // Note: This checks basic validity but might not catch all edge cases of non-standard identifiers. // Consider a library like 'luxon' or 'date-fns-tz' for production validation if needed. } if (!isUpdate && !input.timeZone) { // Required for create throw new Error('Time zone is required.'); } // Appointment Time Validation if (input.appointmentTime) { const time = new Date(input.appointmentTime); if (isNaN(time.getTime())) { throw new Error('Invalid appointment time format. Please use ISO 8601 format.'); } // Optional: Add check if time is in the past? Depends on requirements. // if (time < new Date()) { // throw new Error('Appointment time cannot be in the past.'); // } } if (!isUpdate && !input.appointmentTime) { // Required for create throw new Error('Appointment time is required.'); } // Name Validation if (input.name !== undefined && input.name.trim() === '') { throw new Error('Name cannot be empty.'); } if (!isUpdate && !input.name) { // Required for create throw new Error('Name is required.'); } }; export const appointments: QueryResolvers['appointments'] = () => { // Assumes @requireAuth handles authorization return db.appointment.findMany({ orderBy: { appointmentTime: 'asc' } }) } export const appointment: QueryResolvers['appointment'] = ({ id }) => { // Assumes @requireAuth handles authorization return db.appointment.findUnique({ where: { id }, }) } export const createAppointment: MutationResolvers['createAppointment'] = async ({ input, }) => { // Assumes @requireAuth handles authorization validateAppointmentInput(input); // Centralized validation const appointmentTimeUTC = new Date(input.appointmentTime); // Already validated format let scheduledMessageSid: string | null = null; try { // Schedule the reminder *before* creating the DB record // So if Twilio fails, we don't save the appointment (or handle differently) scheduledMessageSid = await scheduleReminder({ // Pass necessary fields, ID is not available yet id: 0, // Temporary placeholder, not used in scheduling call itself name: input.name, phoneNumber: input.phoneNumber, appointmentTime: appointmentTimeUTC, timeZone: input.timeZone, scheduledMessageSid: null // No existing SID for creation }) } catch (error) { // Error is logged within scheduleReminder // Decide how to proceed: fail creation, or create without reminder? // For this example, we fail the creation if scheduling fails. logger.error({ error, input }, 'Failed scheduling reminder during createAppointment, aborting creation.'); throw error; // Re-throw the error from scheduleReminder } // If scheduling was skipped (e.g., too soon/far), scheduledMessageSid will be null const reminderSentStatus = !!scheduledMessageSid; logger.info(`Creating appointment record for ${input.name} with reminderSent=${reminderSentStatus}`); const createdAppointment = await db.appointment.create({ data: { ...input, appointmentTime: appointmentTimeUTC, // Store UTC Date object reminderSent: reminderSentStatus, scheduledMessageSid: scheduledMessageSid, }, }) return createdAppointment } export const updateAppointment: MutationResolvers['updateAppointment'] = async ({ id, input, }) => { // Assumes @requireAuth handles authorization validateAppointmentInput(input, true); // Centralized validation for updates // Fetch existing appointment to get details needed for cancellation/rescheduling const existingAppointment = await db.appointment.findUnique({ where: { id } }); if (!existingAppointment) { throw new Error(`Appointment with ID ${id} not found.`); } // Prepare data for update, using existing values as fallback const updatedData: Partial<TAppointment> = {}; // Use Prisma type for partial data if (input.name) updatedData.name = input.name; if (input.phoneNumber) updatedData.phoneNumber = input.phoneNumber; if (input.timeZone) updatedData.timeZone = input.timeZone; let appointmentTimeUTC = existingAppointment.appointmentTime; if (input.appointmentTime) { appointmentTimeUTC = new Date(input.appointmentTime); // Already validated updatedData.appointmentTime = appointmentTimeUTC; // Ensure Date object is used } // Determine details for rescheduling, combining existing and new data const detailsForScheduling = { id: existingAppointment.id, name: input.name ?? existingAppointment.name, phoneNumber: input.phoneNumber ?? existingAppointment.phoneNumber, appointmentTime: appointmentTimeUTC, // Use potentially updated time timeZone: input.timeZone ?? existingAppointment.timeZone, scheduledMessageSid: existingAppointment.scheduledMessageSid // Pass existing SID for potential cancellation }; let newScheduledMessageSid: string | null = null; let reminderSentStatus = existingAppointment.reminderSent; // Default to existing status // Only reschedule if relevant fields changed or if it wasn't scheduled before const needsReschedule = input.appointmentTime || input.phoneNumber || input.name || input.timeZone || !existingAppointment.reminderSent; if (needsReschedule) { logger.info(`Rescheduling reminder for updated appointment ${id}`); try { newScheduledMessageSid = await scheduleReminder(detailsForScheduling); reminderSentStatus = !!newScheduledMessageSid; // Update status based on new attempt } catch (error) { // Decide how to proceed. Maybe update DB but flag reminder issue? // Here, we let the update fail if rescheduling fails. logger.error({ error, input, id }, 'Failed rescheduling reminder during updateAppointment, aborting update.'); throw error; // Re-throw the error from scheduleReminder } updatedData.scheduledMessageSid = newScheduledMessageSid; // Store the new SID (or null if scheduling failed/skipped) updatedData.reminderSent = reminderSentStatus; } else { logger.info(`No relevant changes detected for appointment ${id}, skipping reschedule.`); // Keep existing SID and status if no reschedule occurred updatedData.scheduledMessageSid = existingAppointment.scheduledMessageSid; updatedData.reminderSent = existingAppointment.reminderSent; } // Update the database record logger.info(`Updating appointment record ${id} with reminderSent=${updatedData.reminderSent}`); const updatedAppointment = await db.appointment.update({ where: { id }, data: updatedData, }) return updatedAppointment; } export const deleteAppointment: MutationResolvers['deleteAppointment'] = async ({ id, }) => { // Assumes @requireAuth handles authorization logger.info(`Attempting to delete appointment ${id}`); // Find the appointment first to get the scheduledMessageSid const appointmentToDelete = await db.appointment.findUnique({ where: { id } }); if (!appointmentToDelete) { // Already deleted or never existed logger.warn(`Appointment ${id} not found for deletion.`); // Depending on requirements, you might throw an error or return null/undefined // Let's throw for clarity that the requested resource wasn't found. throw new Error(`Appointment with ID ${id} not found.`); } // Attempt to cancel any scheduled reminder *before* deleting if (appointmentToDelete.scheduledMessageSid) { try { logger.info(`Cancelling reminder ${appointmentToDelete.scheduledMessageSid} before deleting appointment ${id}`); // Ensure client is available before attempting cancellation if (twilioClient) { await twilioClient.messages(appointmentToDelete.scheduledMessageSid).update({ status: 'canceled' }); logger.info(`Successfully cancelled reminder for appointment ${id}`); } else { logger.warn(`Twilio client not available, cannot cancel reminder ${appointmentToDelete.scheduledMessageSid} during deletion.`); } } catch (error) { // Log error but proceed with deletion. // Avoid using `error.status === 404` check as it might indicate other issues. // Check Twilio docs for specific error codes if needed (e.g., 20404 usually means Not Found/already processed). logger.error({ error, appointmentId: id, sid: appointmentToDelete.scheduledMessageSid }, `Failed to cancel reminder during deletion. It might have already been sent or canceled.`); } } const deletedAppointment = await db.appointment.delete({ where: { id }, }) logger.info(`Successfully deleted appointment ${id}`); return deletedAppointment } // Resolver for related fields if needed (Redwood handles basic ones) export const Appointment: AppointmentResolvers = { // Example if you needed to resolve a computed field: // formattedAppointmentTime: (_obj, { root }) => { // // Be mindful of server vs client locale when formatting // return new Intl.DateTimeFormat('en-US').format(root.appointmentTime); // }, }
- Twilio Client: Initialized once with error handling and check for credentials.
scheduleReminder
Helper: Encapsulates the logic for calculatingsendAt
, checking Twilio's constraints (15 min - 7 days), canceling previous messages (ifscheduledMessageSid
exists and client is available), formatting the message body with the correct time zone (with fallback), and calling the Twilio API. Now checks for client availability.- UTC Handling: All scheduling logic uses the
appointmentTime
(assumed UTC) directly. ThetimeZone
is used only for formatting the message content for the user. - E.164 Format: Emphasizes storing and sending phone numbers in E.164 format (
+15551234567
) for international compatibility. Validation moved to a helper. - Error Handling: Uses
try...catch
around Twilio calls and logs errors using Redwood'slogger
. Crucially, it handles potential failures during scheduling and cancellation. Added checks for Twilio client initialization. - Validation: Centralized input validation in
validateAppointmentInput
helper for consistency increate
andupdate
. Added timezone validation robustness withtry/catch
. - Cancellation: Implements cancellation logic in
updateAppointment
anddeleteAppointment
using the storedscheduledMessageSid
. It logs errors if cancellation fails but generally proceeds with the primary operation (update/delete). Now checks for client availability before attempting cancellation. - Update Logic: Refined update logic to reschedule only when necessary and handle status updates correctly.
4. Integrating with Twilio
Now, let's get the necessary credentials and configure the Twilio Messaging Service.
-
Get Twilio Account SID and Auth Token:
- Log in to the Twilio Console.
- On the main dashboard (""Account Info""), you'll find your Account SID and Auth Token.
- Copy these values and paste them into the corresponding
TWILIO_ACCOUNT_SID
andTWILIO_AUTH_TOKEN
variables in your.env
file.
-
Purchase a Twilio Phone Number:
- Navigate to Phone Numbers > Manage > Buy a number.
- Select your country and ensure the SMS capability is checked.
- Search for available numbers and buy one that suits your needs. Note the number you purchased.
-
Create and Configure a Twilio Messaging Service:
- Navigate to Messaging > Services in the Twilio Console.
- Click Create Messaging Service.
- Enter a Messaging Service friendly name (e.g., ""Redwood Reminders"").
- For Select what you want to use Messaging for, choose ""Notify my users"" or a relevant option. Click Create Messaging Service.
- Step 2: Add Senders: Click Add Senders.
- Select Sender Type:
Phone Number
. Click Continue. - Select the Twilio phone number you purchased earlier. Click Add Phone Numbers.
- Select Sender Type:
- Step 3: Set up integration: You can usually leave these as defaults unless you have specific needs (like custom webhook URLs, which we don't need for scheduling). Click Step 4: Add compliance info.
- Step 4: Add compliance info (Optional but Recommended): Add any required compliance information if applicable. Click Complete Messaging Service Setup.
- Copy the Messaging Service SID: On the Messaging Service's dashboard page, find the Service SID (it starts with
MG
). Copy this value. - Paste the SID into the
TWILIO_MESSAGING_SERVICE_SID
variable in your.env
file.
Why a Messaging Service? Twilio's Message Scheduling feature requires sending via a Messaging Service. It provides features like sender ID pools, scalability, content intelligence, and advanced opt-out handling, making it superior to sending from a single number directly for applications.
-
Restart Redwood Dev Server: If your development server is running, stop it (
Ctrl+C
) and restart it to load the new environment variables:yarn rw dev
5. Building the Frontend (Web Side)
Now, let's create the user interface for managing appointments using Redwood's generators and React components.
-
Generate Pages and Cells: We need pages to list appointments and a form to create/edit them. Redwood Cells handle data fetching gracefully.
# Generate a page to display appointments yarn rw g page Appointments /appointments # Generate a Cell to fetch and display the list of appointments yarn rw g cell Appointments # Generate a component for the Appointment form yarn rw g component AppointmentForm # Generate pages for creating and editing appointments yarn rw g page NewAppointment /appointments/new yarn rw g page EditAppointment /appointments/{id:Int}/edit yarn rw g cell EditAppointment
-
Implement
AppointmentsCell
: Openweb/src/components/AppointmentsCell/AppointmentsCell.tsx
. This component fetches and displays the list of appointments.(Implementation details for React components like
AppointmentsCell
,AppointmentForm
,AppointmentsPage
,NewAppointmentPage
,EditAppointmentPage
, andEditAppointmentCell
would follow here, including form handling, date/time input (handling time zones and converting to UTC for the API), displaying appointments, and linking between pages. This typically involves standard React/GraphQL client patterns using Redwood's helpers like<Form>
,<Submit>
,<FieldError>
,useMutation
, etc. A full implementation is beyond the scope of this Markdown rewrite task but would be the next logical step in the tutorial.)