Build an Appointment Scheduler with SMS Reminders Using RedwoodJS and Vonage
Learn how to build a full-stack appointment scheduling application with automated SMS reminders using RedwoodJS v8.x and Vonage SMS API v3.24.1. Complete tutorial with code examples.
Learn how to build a full-stack appointment scheduling application with automated SMS reminders using RedwoodJS v8.x and the Vonage SMS API v3.24.1.[1][4] RedwoodJS – an opinionated, full-stack framework built on React, GraphQL, and Prisma – lets you create modern web applications with minimal configuration.[1] Combine it with Vonage's reliable SMS API, and you can send appointment confirmations and reminders to your users automatically.[4]
Real-world applications: This pattern is essential for medical practices (reducing no-show rates by 20-30%), service businesses (HVAC, automotive repair), consulting firms, salons, and educational institutions. Automated SMS reminders improve customer satisfaction and operational efficiency by ensuring clients remember their commitments without manual intervention.[5]
In this tutorial, you'll build a complete appointment scheduling system. Users can book appointments through a web interface, and your application will send SMS confirmations immediately and reminders 24 hours before each appointment. You'll work with RedwoodJS v8.x (2025), Node.js v22 LTS (active until October 2025, maintained until April 2027), Prisma ORM v6.16.0, and the Vonage SDK v3.24.1.[1][2][3][4]
Estimated completion time: 2-3 hours for basic implementation plus 1-2 hours for production deployment and testing. By the end, you'll have a production-ready appointment scheduler with automated SMS notifications.
This guide provides a complete walkthrough for building a web application using the RedwoodJS framework that enables users to book appointments and receive SMS reminders via the Vonage Messages API. We'll cover everything from project setup and core feature implementation to deployment and troubleshooting.
By the end of this tutorial, you'll have a functional RedwoodJS application featuring:
A user interface for selecting and booking appointment slots.
Backend logic to manage appointment availability.
Integration with the Vonage Messages API to send SMS confirmations upon booking.
A scheduled mechanism to automatically send SMS reminders before appointments.
Secure handling of API keys and user data.
A robust structure ready for further development and deployment.
Target Audience: Developers familiar with JavaScript and Node.js, with some exposure to React and database concepts. Prior RedwoodJS experience is helpful but not strictly required.
Technologies Used:
RedwoodJS: A full-stack JavaScript/TypeScript framework built on React, GraphQL, and Prisma. Current version: v8.x (2025). It provides structure, conventions, and tooling for rapid development.[1]
Node.js: The underlying runtime environment for the RedwoodJS API side. Node.js v22 LTS is recommended (active LTS until October 2025, maintained until April 2027).[2]
React: Used for building the user interface on the RedwoodJS web side.
GraphQL: The communication layer between the RedwoodJS web and API sides.
Prisma: A next-generation ORM for database access and migrations. Current version: v6.16.0 (2025), featuring the Rust-free architecture for improved performance and smaller bundle sizes.[3]
Vonage Messages API: Used to send SMS confirmations and reminders (using @vonage/server-sdk v3.24.1).[4]
PostgreSQL (or SQLite/MySQL): The database for storing appointment data. (This guide uses PostgreSQL syntax where applicable, but Prisma makes switching easy).
External Scheduler (e.g., Vercel Cron Jobs, Netlify Scheduled Functions, OS cron): To trigger reminder checks via a Redwood Function.[6][7]
Project Overview and Goals
Business Context: No-shows cost service businesses an estimated $150 billion annually in the US alone. Automated appointment reminders can reduce no-shows by 20-30%, directly impacting revenue and operational efficiency. This system addresses the core problem: ensuring customers remember their commitments while minimizing manual administrative overhead.
We aim to build an application that solves the common problem of scheduling appointments and reducing no-shows through automated reminders with minimal human intervention.
Core Features:
Appointment Booking: Users can view available time slots and book an appointment by providing their phone number.
Instant Confirmation: Upon successful booking, the user receives an immediate SMS confirmation containing the appointment details and a unique cancellation code.
Automated Reminders: The system automatically sends an SMS reminder to the user a configurable amount of time (e.g., 1 hour) before their scheduled appointment via a scheduled task.
Appointment Cancellation: Users can cancel their appointment using the unique code provided in the confirmation SMS (Implementation of cancellation UI/API is outlined but left as an extension).
Web Side: Handles user interaction (displaying the form, submitting data). Communicates with the API side via GraphQL over HTTPS.
API Side: Contains business logic (checking availability, saving appointments, interacting with Vonage). Exposes a GraphQL API and serverless functions.
Database: Stores appointment information (time, user phone number, reminder status) via Prisma ORM with connection pooling.
Vonage API: External service used by the API side to send SMS messages following E.164 phone number standards.[8]
External Scheduler: A process (like Vercel Cron Jobs or system cron) runs periodically to trigger a Redwood Function (/api/sendReminders) which checks for upcoming appointments and triggers reminder SMS via the Vonage API.
Prerequisites:
Node.js (v22 LTS recommended, minimum v18+) and npm or Yarn (v1 or later recommended)[2]
Yarn (v1 or later) - This guide uses yarn commands, but npm equivalents generally work.
Vonage Virtual Number: Purchase or rent a virtual phone number with SMS capability from the Vonage Dashboard. This number must be in E.164 format (e.g., +14155550100) and will be used as the sender for all SMS messages.[8]
API Credentials: Obtain your API Key and API Secret from the Vonage Dashboard under "API settings".[8]
Access to a PostgreSQL database (or choose SQLite/MySQL during Prisma setup)
Basic command-line/terminal familiarity
Tailwind CSS: The frontend examples assume Tailwind CSS is installed and configured in the RedwoodJS project (which is the default for new Redwood projects). Basic inline styles could be substituted if not using Tailwind.
1. Setting Up the RedwoodJS Project
Let's initialize our RedwoodJS application and configure the basic structure.
Create RedwoodJS App:
Open your terminal and run:
Bash
yarn create redwood-app ./vonage-scheduler
cd vonage-scheduler
Follow the prompts. Choose TypeScript if you prefer, though this guide uses JavaScript.
Common installation issues:
Port conflicts: If port 8910 is in use, RedwoodJS will prompt for an alternative or set PORT=3000 in .env
Yarn version issues: Ensure Yarn v1.22.0+ is installed (yarn --version)
Node version: Verify Node.js v18+ with node --version
Install Dependencies:
We need the Vonage Server SDK v3.24.1, uuid, and date-fns.[4]
Bash
yarn workspace api add @vonage/server-sdk uuid date-fns
Note: node-cron is removed as the scheduling logic relies on an external trigger for the function, not an in-process cron scheduler.
Database Setup (Prisma):
RedwoodJS uses Prisma v6.16.0 for database interaction.[3]
Open api/db/schema.prisma.
Configure the datasource db block for your chosen database. For PostgreSQL with timezone support:
// api/db/schema.prisma
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// ... rest of the file
Define Database Schema:
Add the Appointment model to api/db/schema.prisma:
Prisma
// api/db/schema.prisma
model Appointment {
id Int @id @default(autoincrement())
slotDateTime DateTime @db.Timestamptz(6) // PostgreSQL: Timezone-aware timestamp stored in UTC [9]
phoneNumber String // User's phone number (E.164 format strongly recommended) [8]
bookingCode String @unique // Unique code for cancellation/lookup
confirmed Boolean @default(false) // Was booking confirmation SMS sent successfully?
reminderSent Boolean @default(false) // Has the reminder SMS been sent successfully?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([slotDateTime]) // B-tree index for efficient time-based queries
@@index([reminderSent, confirmed, slotDateTime]) // Composite index for reminder queries
}
Field explanations:
slotDateTime: Stores the exact date and time using PostgreSQL's TIMESTAMPTZ(6) type, which stores timestamps in UTC with timezone awareness. This prevents timezone-related bugs in distributed systems.[9]
phoneNumber: Stores the recipient number for SMS in E.164 format (+[country code][number], max 15 digits). E.164 is the international standard required by Vonage APIs.[8]
bookingCode: A unique identifier generated during booking (8-character UUID substring).
confirmed, reminderSent: Boolean flags to track SMS delivery status.
Indexing strategy: The single-column index on slotDateTime accelerates booking availability checks. The composite index (reminderSent, confirmed, slotDateTime) optimizes the reminder query that filters by status flags and time range.[9]
Create and Apply Migration:
This command creates SQL migration files based on your schema changes and applies them to your database.
Bash
yarn rw prisma migrate dev
Enter a name for the migration when prompted (e.g., add_appointment_model).
Environment Variables (.env):
RedwoodJS uses a .env file at the project root for environment variables. Create it if it doesn't exist and add your database connection string and Vonage credentials. Consider using .env.defaults for non-secret default values like APPOINTMENT_REMINDER_MINUTES_BEFORE.
.env
# .env
# === Database ===
# Example for PostgreSQL: DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
# Example for SQLite: DATABASE_URL="file:./dev.db"
DATABASE_URL="YOUR_DATABASE_CONNECTION_STRING"
# === Vonage API ===
# Get these from your Vonage Dashboard: https://dashboard.nexmo.com/settings
VONAGE_API_KEY="YOUR_VONAGE_API_KEY"
VONAGE_API_SECRET="YOUR_VONAGE_API_SECRET"
# This is a virtual number you purchase/rent from Vonage used to send SMS
# Must be in E.164 format: +[country code][number] (e.g., +14155550100)
VONAGE_FROM_NUMBER="YOUR_VONAGE_VIRTUAL_NUMBER"
# === Application Settings ===
# Reminder time in minutes before the appointment
APPOINTMENT_REMINDER_MINUTES_BEFORE="60"
# === Security (Example for Cron Trigger) ===
# A secret shared between your scheduler and the function
# Generate with: openssl rand -hex 32
# CRON_SECRET="YOUR_STRONG_RANDOM_SECRET"
Security best practices for credentials:[10][11]
Never commit secrets to version control: Add .env to .gitignore (RedwoodJS does this by default).
Use different credentials per environment: Separate API keys for dev, staging, and production.
Rotate credentials regularly: Vonage allows generating new API secrets in the dashboard. Plan quarterly rotations.
Consider secrets management services: For production, use dedicated secrets management (AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, or 1Password CLI) instead of environment variables. These services provide audit logs, automatic rotation, and fine-grained access control.[10][11]
Limit credential permissions: Vonage API keys should have minimum required permissions.
Production credential handling: When deploying to Vercel/Netlify, use their environment variable interfaces which encrypt secrets at rest.
Initialize Vonage Client (API Side):
Create a utility file to initialize the Vonage client instance using the v3.24.1 SDK.[4]
JavaScript
// api/src/lib/vonage.jsimport { Vonage } from'@vonage/server-sdk'import { SMS } from'@vonage/messages'import { logger } from'src/lib/logger'// Redwood's built-in loggerlet vonageInstance
exportconstgetVonageClient = () => {
if (vonageInstance) {
return vonageInstance
}
const apiKey = process.env.VONAGE_API_KEYconst apiSecret = process.env.VONAGE_API_SECRETif (!apiKey || !apiSecret) {
logger.error('Vonage API Key or Secret is missing in environment variables.')
// In production, throw an error to prevent startup without credentialsthrownewError('Vonage credentials not configured.')
}
try {
// Using v3.24.1 SDK initialization
vonageInstance = newVonage({
apiKey: apiKey,
apiSecret: apiSecret,
// applicationId: 'YOUR_VONAGE_APPLICATION_ID', // Optional: Needed for Voice/Video APIs// privateKey: './private.key', // Optional: Needed for JWT-authenticated APIs
})
logger.info('Vonage client initialized successfully (v3.24.1 SDK).')
return vonageInstance
} catch (error) {
logger.error({ error }, 'Failed to initialize Vonage client')
throw error // Re-throw error to prevent silent failures
}
}
// Helper function for sending SMS using Vonage Messages API (v3 SDK)// Returns { success: boolean, messageId?: string, error?: string, details?: any }exportconstsendSms = async (to, text) => {
const vonage = getVonageClient() // Throws if not configuredconstfrom = process.env.VONAGE_FROM_NUMBERif (!from || !to || !text) {
const errorMsg = 'Cannot send SMS. Missing "from", "to", or "text".'
logger.error(errorMsg)
return { success: false, error: errorMsg }
}
// E.164 format validation: + optional, then 1-9, then 1-14 more digits [8]// Max length: 15 digits total. Country code: 1-3 digits. Subscriber number: remainder.if (!/^\+?[1-9]\d{1,14}$/.test(to) || !/^\+?[1-9]\d{1,14}$/.test(from)) {
const errorMsg = `Invalid phone number format. 'to' or 'from' must be E.164 format. To: ${to}, From: ${from}`
logger.error(errorMsg)
return { success: false, error: errorMsg }
}
logger.info({ to, from }, 'Attempting to send SMS via Vonage Messages API')
try {
// Using v3 SDK Messages API// Vonage SDK includes automatic retry for transient network errors (3 attempts by default)const response = await vonage.messages.send(newSMS(text, to, from))
if (response.messages && response.messages[0]) {
const messageId = response.messages[0]['message-id']
logger.info({ messageId, to }, 'SMS sent successfully')
return { success: true, messageId }
} else {
logger.warn({ response }, 'SMS sent but unexpected response format')
return { success: true, details: response }
}
} catch (error) {
// Vonage API errors: insufficient balance (402), invalid number (422), rate limit (429)
logger.error({ error, to, errorCode: error.statusCode }, 'Failed to send SMS via Vonage')
return {
success: false,
error: error.message || 'Unknown error',
details: error
}
}
}
Error handling notes:
Throws errors during initialization if credentials are missing to fail fast.
sendSms returns a consistent { success: boolean, ... } object for graceful error handling.
Vonage SDK v3 includes automatic retries for network errors (exponential backoff).
Logs error codes for debugging common issues: 402 (insufficient credit), 422 (invalid number), 429 (rate limit).
2. Implementing Core Functionality (Booking)
Now, let's build the GraphQL API and the service logic for creating appointments.
Generate SDL and Service:
Redwood's generators scaffold the necessary files.
Bash
yarn rw g sdl Appointment --crud
# This creates api/src/graphql/appointments.sdl.js and api/src/services/appointments/appointments.js# It also generates basic CRUD operations. We will modify createAppointment.
Define GraphQL Schema (SDL):
Modify the generated appointments.sdl.js (or .graphql file if preferred) to include a specific input type for creation and define the createAppointment mutation.
GraphQL
# api/src/graphql/appointments.sdl.js or api/src/graphql/appointments.sdl.ts# If using .sdl.js/ts, ensure gql import is present:# import gql from 'graphql-tag'
export const schema= gql`
type Appointment {id: Int!slotDateTime: DateTime!phoneNumber: String!bookingCode: String!confirmed: Boolean!reminderSent: Boolean!createdAt: DateTime!updatedAt: DateTime!}# Input type for creating appointmentsinput CreateAppointmentInput {slotDateTime: DateTime!# Expecting ISO 8601 String (UTC recommended)phoneNumber: String!# E.164 format strongly recommended}typeQuery{# Example query (optional for this guide)appointments:[Appointment!]!@requireAuth}typeMutation{# Mutation to create a new appointment
createAppointment(input: CreateAppointmentInput!): Appointment!@skipAuth# We keep other generated mutations for now, but focus on create# updateAppointment(id: Int!, input: UpdateAppointmentInput!): Appointment! @requireAuth# deleteAppointment(id: Int!): Appointment! @requireAuth}
`
CreateAppointmentInput: Defines the data needed from the client (web side).
createAppointment: The mutation the web side will call.
@skipAuth: For simplicity in this guide, we bypass authentication. In a real app, you'd use @requireAuth and implement Redwood Auth (yarn rw setup auth ...).
Added an example Query block, although not the focus here.
Implement the Service Logic:
Update the createAppointment function in api/src/services/appointments/appointments.js.
JavaScript
// api/src/services/appointments/appointments.jsimport { db } from'src/lib/db'import { logger } from'src/lib/logger'import { sendSms } from'src/lib/vonage'// Import our updated helperimport { v4 as uuidv4 } from'uuid'// For generating booking codesimport { UserInputError } from'@redwoodjs/graphql-server'// For validation errors// Recommended: Add robust phone number validation// import { parsePhoneNumberFromString } from 'libphonenumber-js'exportconstappointments = () => {
// Example implementation for the Query defined in SDL// Ensure proper authorization if using @requireAuth// import { requireAuth } from 'src/lib/auth'// requireAuth()return db.appointment.findMany()
}
exportconstcreateAppointment = async ({ input }) => {
logger.info({ input }, 'Received request to create appointment')
const { slotDateTime, phoneNumber } = input
// --- 1. Input Validation ---if (!slotDateTime || !phoneNumber) {
thrownewUserInputError('Missing required fields: slotDateTime and phoneNumber')
}
// **Robust Phone Number Validation (Recommended)**// Use a library like libphonenumber-js for proper E.164 validation & formatting// yarn workspace api add libphonenumber-js// const parsedNumber = parsePhoneNumberFromString(phoneNumber)// if (!parsedNumber || !parsedNumber.isValid()) {// throw new UserInputError('Invalid phone number format. Please use E.164 format (e.g., +15551234567).')// }// const formattedPhoneNumber = parsedNumber.format('E.164') // Store normalized format// Basic validation (Use libphonenumber-js in production!)if (!/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) {
thrownewUserInputError('Invalid phone number format. Please use E.164 format (e.g., +15551234567).')
}
const formattedPhoneNumber = phoneNumber // Use validated/formatted number laterlet appointmentTime
try {
appointmentTime = newDate(slotDateTime)
if (isNaN(appointmentTime.getTime())) {
thrownewError('Invalid date format')
}
} catch (e) {
thrownewUserInputError('Invalid date format for slotDateTime. Please use ISO 8601 format.')
}
const now = newDate()
// Ensure appointment is in the future (minimum 1 hour buffer)const minBookingTime = newDate(now.getTime() + 60 * 60 * 1000) // 1 hour from nowif (appointmentTime <= minBookingTime) {
thrownewUserInputError('Appointment must be at least 1 hour in the future.')
}
// --- 2. Check Availability with Transaction for Race Condition Prevention ---// Prisma interactive transactions prevent race conditions in high-traffic scenarios// Alternative: Add a unique constraint on slotDateTime in schema for database-level enforcementtry {
const result = await db.$transaction(async (prisma) => {
const existingAppointment = await prisma.appointment.findFirst({
where: { slotDateTime: appointmentTime },
})
if (existingAppointment) {
thrownewUserInputError('This appointment slot is already booked.')
}
// --- 3. Generate Booking Code ---// Using 8-char UUID substring: ~16^8 = 4.3B combinations// Collision probability acceptable for moderate volume (<100k appointments)// For high volume, use full UUID or database sequenceconst bookingCode = uuidv4().substring(0, 8).toUpperCase()
// --- 4. Save to Database ---const newAppointment = await prisma.appointment.create({
data: {
slotDateTime: appointmentTime, // Stored as UTC in TIMESTAMPTZ fieldphoneNumber: formattedPhoneNumber,
bookingCode: bookingCode,
confirmed: false, // Mark as not confirmed until SMS succeedsreminderSent: false,
},
})
return newAppointment
})
logger.info({ appointmentId: result.id, bookingCode: result.bookingCode }, 'Appointment saved to database')
// --- 5. Send Confirmation SMS ---// Format time in user-friendly way (could accept timezone from client for localization)const confirmationText = `Your appointment is booked for ${result.slotDateTime.toLocaleString()}. Your booking code: ${result.bookingCode}.`const smsResult = awaitsendSms(result.phoneNumber, confirmationText)
// --- 6. Update Confirmation Status Based on SMS Result ---if (smsResult.success) {
logger.info({ appointmentId: result.id, messageId: smsResult.messageId }, 'Confirmation SMS sent successfully.')
try {
const updatedAppt = await db.appointment.update({
where: { id: result.id },
data: { confirmed: true },
})
result.confirmed = updatedAppt.confirmed
logger.info({ appointmentId: result.id }, 'Appointment confirmed status updated in DB.')
} catch (updateError) {
logger.error({ updateError, appointmentId: result.id }, 'Failed to update confirmation status after SMS success.')
}
} else {
logger.error({ appointmentId: result.id, error: smsResult.error }, 'Failed to send confirmation SMS.')
// Options: 1) Return with confirmed:false (current), 2) Add to retry queue, 3) Delete appointment
}
return result
} catch (error) {
// Handle Prisma transaction errors and validation errorsif (error instanceofUserInputError) {
throw error
}
if (error.code === 'P2002' && error.meta?.target?.includes('bookingCode')) {
logger.error('Booking code collision detected (very rare). Retry recommended.')
thrownewError('Failed to generate unique booking code. Please try again.')
}
logger.error({ error }, 'Failed to create appointment')
thrownewError('Failed to save appointment. Please try again.')
}
}
Race condition handling:
Uses Prisma $transaction to wrap availability check and creation in a single database transaction.
This ensures atomic read-then-write, preventing double-booking even under concurrent requests.
Alternative: Add @@unique([slotDateTime]) constraint in Prisma schema for database-level enforcement.
Create the Form Component:
Modify web/src/pages/AppointmentBookingPage/AppointmentBookingPage.js.
JavaScript
// web/src/pages/AppointmentBookingPage/AppointmentBookingPage.jsimport { useState } from'react'import { MetaTags, useMutation } from'@redwoodjs/web'import { toast, Toaster } from'@redwoodjs/web/toast'// For notificationsimport { Form, TextField, DatetimeLocalField, Submit, Label, FieldError } from'@redwoodjs/forms'import gql from'graphql-tag'// GraphQL Mutation matching the one defined in the SDLconstCREATE_APPOINTMENT_MUTATION = gql`mutation CreateAppointmentMutation($input: CreateAppointmentInput!){
createAppointment(input:$input) {
id
slotDateTime
phoneNumber
bookingCode
confirmed
}}
`constAppointmentBookingPage = () => {
const [formKey, setFormKey] = useState(Date.now()) // Used to reset formconst [createAppointment, { loading, error }] = useMutation(
CREATE_APPOINTMENT_MUTATION,
{
onCompleted: (data) => {
if (data.createAppointment.confirmed) {
toast.success(
`Appointment booked successfully! Code: ${data.createAppointment.bookingCode}. Check your phone for confirmation.`,
{ duration: 8000 }
)
} else {
// Use warning toast if confirmation failed but booking succeeded
toast.warn(
`Appointment booked (Code: ${data.createAppointment.bookingCode}), but confirmation SMS failed. Please contact support.`,
{ duration: 10000 }
)
}
// Reset form by changing the key propsetFormKey(Date.now())
},
onError: (error) => {
// Check for user input errors vs server errorsconst isUserError = error.graphQLErrors?.some(
err => err.extensions?.code === 'BAD_USER_INPUT'
)
if (isUserError) {
toast.error(`Booking failed: ${error.message}`)
} else {
toast.error('Server error. Please try again or contact support.')
}
},
}
)
constonSubmit = (formData) => {
// Convert datetime-local (browser local time) to ISO 8601 UTC stringconst input = {
slotDateTime: newDate(formData.slotDateTime).toISOString(),
phoneNumber: formData.phoneNumber,
}
createAppointment({ variables: { input } })
}
// Tailwind CSS classesconst labelStyle = "block text-sm font-medium text-gray-700 mb-1"const inputStyle = "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"const errorStyle = "mt-1 text-xs text-red-600"// Calculate minimum datetime: 1 hour from nowconstgetMinDateTime = () => {
const now = newDate()
now.setHours(now.getHours() + 1)
return now.toISOString().slice(0, 16) // Format: YYYY-MM-DDTHH:mm
}
return (
<><MetaTagstitle="Book Appointment"description="Book your appointment slot" /><ToastertoastOptions={{className: 'rw-toast', duration:6000 }} /><divclassName="max-w-md mx-auto mt-8 p-6 bg-white rounded-lg shadow-md"role="main"><h1className="text-2xl font-semibold mb-4 text-center">Book Your Appointment</h1><Formkey={formKey}onSubmit={onSubmit}config={{mode: 'onBlur' }}><divclassName="mb-4"><Labelname="slotDateTime"className={labelStyle}errorClassName={`${labelStyle} text-red-700`}
>
Choose date and time:
</Label><DatetimeLocalFieldname="slotDateTime"className={inputStyle}errorClassName={`${inputStyle} border-red-500`}
validation={{required: 'Appointmentdateandtimeisrequired', valueAsDate:true }}
min={getMinDateTime()}aria-describedby="datetime-help"
/><pid="datetime-help"className="mt-1 text-xs text-gray-500">
Must be at least 1 hour in the future
</p><FieldErrorname="slotDateTime"className={errorStyle} /></div><divclassName="mb-4"><Labelname="phoneNumber"className={labelStyle}errorClassName={`${labelStyle} text-red-700`}
>
Phone number:
</Label><TextFieldname="phoneNumber"className={inputStyle}errorClassName={`${inputStyle} border-red-500`}
validation={{required: 'Phonenumberisrequired',
pattern: {
value: /^\+?[1-9]\d{1,14}$/,
message: 'UseE.164format (e.g., +15551234567)'
}
}}
placeholder="+15551234567"aria-describedby="phone-help"
/><pid="phone-help"className="mt-1 text-xs text-gray-500">
International format: +[country code][number]
</p><FieldErrorname="phoneNumber"className={errorStyle} /></div>
{error && (
<divclassName="mb-4 p-3 bg-red-100 text-red-700 rounded"role="alert">
Error: {error.message}
</div>
)}
<Submitdisabled={loading}className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors"aria-label={loading ? 'Bookinginprogress' : 'Bookappointment'}
>
{loading ? 'Booking...' : 'Book Appointment'}
</Submit></Form></div></>
)
}
exportdefaultAppointmentBookingPage
Form validation strategy:
Client-side validation with React Hook Form (via RedwoodJS Forms) provides immediate feedback.
onBlur mode validates fields when user leaves the field.
Server-side validation in GraphQL service is the authoritative check.
User feedback via toast notifications distinguishes between user errors and system errors.
Accessibility considerations:
role="main" landmark for main content.
aria-describedby links help text to form fields.
aria-label provides context for submit button states.
role="alert" announces errors to screen readers.
Keyboard navigation fully supported (Tab, Enter).
Run the Development Server:
Bash
yarn rw dev
Navigate to http://localhost:8910/book (or the port specified). You should see the form. Try booking an appointment. Check your terminal logs (api side) and your phone for the SMS confirmation! Check the database to see the confirmed flag.
4. Implementing Scheduled Reminders
We need a mechanism to periodically check for upcoming appointments and send reminders. We'll use a RedwoodJS Function triggered by an external scheduler. Running cron within a serverless function is unreliable because serverless instances are ephemeral.
Scheduling Approaches Comparison:
Approach
Pros
Cons
Best For
Vercel Cron Jobs
Built-in, no extra infrastructure, free tier available
Vercel-only, limited to production deployments
Vercel-hosted apps
Netlify Scheduled Functions
Native integration, simple configuration
Netlify-only, beta feature, 30s timeout
Netlify-hosted apps
System Cron (Linux/macOS)
Full control, reliable, no vendor lock-in
Requires server access, manual setup
Self-hosted or VPS deployments
GitHub Actions Schedule
Free for public repos, CI/CD integration
Minimum 5-minute intervals, rate limits
Open-source projects
RedwoodJS Background Jobs
Native RedwoodJS feature, built-in scheduling
Requires v8.0+, needs dedicated worker process
Complex job queues
Recommended: Use platform-native solutions (Vercel Cron or Netlify Scheduled Functions) for simplicity, or system cron for maximum reliability.[6][7]
Create a Redwood Function:
Bash
yarn rw g function sendReminders
# Creates api/src/functions/sendReminders.js
Implement the Function Logic:
Edit api/src/functions/sendReminders.js:
JavaScript
// api/src/functions/sendReminders/sendReminders.jsimport { db } from'src/lib/db'import { logger } from'src/lib/logger'import { sendSms } from'src/lib/vonage'import { add } from'date-fns'exportconsthandler = async (event, _context) => {
logger.info('sendReminders function triggered')
// Security: Verify cron secret to prevent unauthorized invocationsconst cronSecret = process.env.CRON_SECRETif (cronSecret) {
const providedSecret = event.headers['x-cron-secret'] || event.queryStringParameters?.secretif (providedSecret !== cronSecret) {
logger.warn('Unauthorized attempt to trigger sendReminders function')
return {
statusCode: 401,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Unauthorized' }),
}
}
}
try {
const reminderMinutes = parseInt(process.env.APPOINTMENT_REMINDER_MINUTES_BEFORE || '60', 10)
const now = newDate()
const reminderWindow = add(now, { minutes: reminderMinutes })
// Find appointments needing reminders: not sent, confirmed, within reminder windowconst appointmentsToRemind = await db.appointment.findMany({
where: {
reminderSent: false,
confirmed: true,
slotDateTime: {
lte: reminderWindow,
gte: now,
},
},
})
logger.info(`Found ${appointmentsToRemind.length} appointments needing reminders`)
let successCount = 0let failureCount = 0const failedAppointments = []
// Process reminders sequentially to avoid overwhelming SMS API rate limits// For high volume (>100 reminders), implement batching with delaysfor (const appointment of appointmentsToRemind) {
const reminderText = `Reminder: Your appointment is scheduled for ${appointment.slotDateTime.toLocaleString()}. Booking code: ${appointment.bookingCode}`const smsResult = awaitsendSms(appointment.phoneNumber, reminderText)
if (smsResult.success) {
// Mark as sent immediately to prevent duplicate sends if function times outawait db.appointment.update({
where: { id: appointment.id },
data: { reminderSent: true },
})
successCount++
logger.info({ appointmentId: appointment.id }, 'Reminder sent successfully')
} else {
failureCount++
failedAppointments.push({ id: appointment.id, error: smsResult.error })
logger.error({ appointmentId: appointment.id, error: smsResult.error }, 'Failed to send reminder')
// Retry strategy: Failed reminders remain with reminderSent:false// Next cron run will retry (idempotent)
}
}
const response = {
message: 'Reminder processing complete',
total: appointmentsToRemind.length,
success: successCount,
failed: failureCount,
failedAppointments: failedAppointments.length > 0 ? failedAppointments : undefined,
}
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(response),
}
} catch (error) {
logger.error({ error }, 'Error in sendReminders function')
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Internal server error', details: error.message }),
}
}
}
Retry logic and failure recovery:
Failed reminders keep reminderSent: false, so next cron run retries automatically (idempotent).
Successful sends mark reminderSent: true immediately to prevent duplicates if function times out.
Sequential processing prevents rate limit issues. For high volume (>100/run), add await new Promise(r => setTimeout(r, 100)) between sends.
Consider: Implement exponential backoff or dead-letter queue for persistent failures after 3 attempts.
Edit api/src/functions/sendReminders.js to add config export:
JavaScript
// At the top of api/src/functions/sendReminders.jsimport type { Config } from"@netlify/functions"exportconstconfig: Config = {
schedule: "@hourly"// Or "*/15 * * * *" for every 15 minutes
}
// ... rest of handler code
Credential rotation: Plan quarterly rotation of Vonage API keys and database passwords
Monitor logs: Never log full process.env or secrets. Redwood's logger redacts sensitive fields by default.
CRON_SECRET security: Treat as sensitive. Rotate if exposed. Consider IP allowlisting if possible.
6. Testing Your Application
Test the Booking Flow:
Navigate to /book in your browser
Select a future date and time (at least 1 hour ahead)
Enter your phone number in E.164 format (e.g., +15551234567)
Submit the form
Check your phone for the confirmation SMS within 5-10 seconds
Verify the appointment appears in your database:
Bash
yarn rw prisma studio
Check that confirmed: true if SMS was successful
Test the Reminder System:
Create a test appointment 1 hour in the future (or adjust APPOINTMENT_REMINDER_MINUTES_BEFORE to 5 minutes for quick testing)
Manually trigger the function:
Bash
# Local testing
curl -X GET http://localhost:8911/.redwood/functions/sendReminders -H "x-cron-secret: YOUR_CRON_SECRET"# Production testing
curl -X GET https://your-app.com/.redwood/functions/sendReminders -H "x-cron-secret: YOUR_CRON_SECRET"
Check your phone for the reminder SMS
Verify reminderSent: true in database
Automated Testing Strategy:
RedwoodJS includes Jest for unit and integration testing.
Service Tests (api/src/services/appointments/appointments.test.js):
// Debug in function
logger.info({ now, reminderWindow, reminderMinutes }, 'Reminder window calculated')
Appointments exist: Query database for eligible appointments
Bash
yarn rw prisma studio
# Filter: reminderSent = false, confirmed = true, slotDateTime within window
GraphQL Mutation Failures:
SDL schema mismatch: Ensure appointments.sdl.js types match service return values
Bash
yarn rw dev
# Open GraphQL Playground: http://localhost:8911/graphql
Input validation: Check server logs for validation errors
Bash
# API logs show in terminal running `yarn rw dev`
Test in GraphQL Playground:
GraphQL
mutation CreateTest {
createAppointment(input:{slotDateTime:"2025-10-15T14:00:00Z"phoneNumber:"+15551234567"}){
id
bookingCode
confirmed
}}
CORS issues: RedwoodJS configures CORS automatically. If issues persist, check api/src/functions/graphql.js
Frequently Asked Questions
How do I change the reminder timing for appointments?
Adjust the APPOINTMENT_REMINDER_MINUTES_BEFORE environment variable in your .env file (or hosting platform environment config). Set it to 60 for 1 hour, 1440 for 24 hours, or any value in minutes. The sendReminders function uses this value to calculate the reminder window:
JavaScript
// Reminder sent when: now < appointment_time <= (now + APPOINTMENT_REMINDER_MINUTES_BEFORE)const reminderWindow = add(now, { minutes: parseInt(process.env.APPOINTMENT_REMINDER_MINUTES_BEFORE) })
For multiple reminder times (e.g., 24 hours AND 1 hour before), add a remindersSent JSON field to track which reminders were sent, and modify the function logic accordingly.
Can I send reminders through WhatsApp instead of SMS?
Yes. Vonage supports WhatsApp messaging through the Messages API. Modifications required:[4]
Set up WhatsApp Business Account with Vonage (requires approval process)
Replace SMS class in sendSms function:
JavaScript
import { WhatsApp } from'@vonage/messages'// Instead of: new SMS(text, to, from)const message = newWhatsApp({
to: to,
from: from, // Your WhatsApp Business Numbermessage_type: 'text',
text: text
})
const response = await vonage.messages.send(message)
Update phone number requirements: WhatsApp requires users to opt-in first. Store opt-in status in database.
Message templates: WhatsApp enforces message templates for business-initiated messages. Register templates in Vonage Dashboard.
For user-specific appointment views, add userId to Appointment model and filter queries by currentUser.id.
Can I customize the SMS message content?
Yes. Modify the confirmationText and reminderText in your code:
Confirmation SMS (in appointments.js service):
JavaScript
const confirmationText = `✓ Appointment confirmed for ${result.slotDateTime.toLocaleString()}. Code: ${result.bookingCode}. Reply CANCEL to cancel.`
Reminder SMS (in sendReminders.js function):
JavaScript
const reminderText = `⏰ Reminder: Appointment in ${reminderMinutes} minutes (${appointment.slotDateTime.toLocaleString()}). Code: ${appointment.bookingCode}. See you soon!`
Best practices:
Keep under 160 characters to avoid multi-part SMS charges (each part costs separately)
GSM-7 character set (no emoji) = 160 chars. Unicode (with emoji) = 70 chars per segment.
Include: appointment time, booking code, clear call-to-action
Consider timezone: Convert UTC to user's local time for display
Comply with TCPA regulations (US): Include opt-out instructions, identify sender
How do I handle different time zones for appointments?
Best practice: Store in UTC, display in local time.[9]
Database storage:
Prisma
model Appointment {
slotDateTime DateTime @db.Timestamptz(6) // PostgreSQL timezone-aware type [9]
// Stores as UTC internally, converts to local on retrieval
}
Start by creating a new RedwoodJS app using yarn create redwood-app ./vonage-scheduler. Then, install necessary dependencies like @vonage/server-sdk, uuid, and date-fns using yarn. Set up your database connection in api/db/schema.prisma, define the Appointment model within the schema, and apply the migration using yarn rw prisma migrate dev.
The Vonage Messages API is used to send SMS confirmations and reminders to users who book appointments through your RedwoodJS application. It's integrated using the @vonage/server-sdk which is initialized with your Vonage API key and secret.
RedwoodJS utilizes Prisma as its Object-Relational Mapper (ORM) for database interactions. This simplifies database access, migrations, and management within the application. Prisma also makes it easy to switch databases (PostgreSQL, SQLite, MySQL).
The article suggests sending SMS reminders a configurable amount of time before the appointment. The timing is controlled by an environment variable (APPOINTMENT_REMINDER_MINUTES_BEFORE) and an external scheduler triggers the reminder function.
Yes, RedwoodJS, through Prisma, supports SQLite and MySQL in addition to PostgreSQL. Adjust the provider and URL settings within your schema.prisma file to switch databases.
Generate a RedwoodJS page with yarn rw g page AppointmentBooking /book and modify the generated component to include a form with fields for selecting the appointment time and entering a phone number. The form submission should trigger the createAppointment mutation.
An external scheduler like Vercel Cron Jobs or OS cron is used to periodically trigger a RedwoodJS function (/api/sendReminders) responsible for checking upcoming appointments and sending reminder SMS messages. This function interacts with both the database and the Vonage API.
Create a helper function (api/src/lib/vonage.js) to initialize the Vonage client using @vonage/server-sdk. Then within your services, import and call this function to send SMS messages via the Vonage API. Ensure API keys and secrets are stored securely in environment variables.
The sendSms helper function in the article is designed to handle common Vonage API errors by returning a specific object that includes success status, any error messages and details, and an optional message ID. The service then logs these details and uses the success status to update appointment confirmation status appropriately.
The booking code provides a unique identifier for each appointment. Although not fully implemented in this example, it's intended to facilitate appointment cancellation or lookup functionalities in a more complete application.
Reminders are sent via a dedicated Redwood Function (/api/sendReminders) triggered externally by a scheduler. This function queries the database for upcoming appointments and sends SMS reminders using the Vonage API.
The E.164 format ensures consistent and reliable phone number handling, crucial for SMS delivery. It's the internationally recommended standard and using it avoids issues caused by varying national number formats. Libraries like libphonenumber-js provide robust validation.
You'll need Node.js, Yarn, a Vonage API account with a virtual number, access to a database (PostgreSQL, SQLite, or MySQL), and basic command-line familiarity. The frontend examples also assume Tailwind CSS is set up.
Learn how to build a full-stack appointment scheduling application with automated SMS reminders using RedwoodJS v8.x and the Vonage SMS API v3.24.1.[1][4] RedwoodJS – an opinionated, full-stack framework built on React, GraphQL, and Prisma – lets you create modern web applications with minimal configuration.[1] Combine it with Vonage's reliable SMS API, and you can send appointment confirmations and reminders to your users automatically.[4]
Real-world applications: This pattern is essential for medical practices (reducing no-show rates by 20-30%), service businesses (HVAC, automotive repair), consulting firms, salons, and educational institutions. Automated SMS reminders improve customer satisfaction and operational efficiency by ensuring clients remember their commitments without manual intervention.[5]
In this tutorial, you'll build a complete appointment scheduling system. Users can book appointments through a web interface, and your application will send SMS confirmations immediately and reminders 24 hours before each appointment. You'll work with RedwoodJS v8.x (2025), Node.js v22 LTS (active until October 2025, maintained until April 2027), Prisma ORM v6.16.0, and the Vonage SDK v3.24.1.[1][2][3][4]
Estimated completion time: 2-3 hours for basic implementation plus 1-2 hours for production deployment and testing. By the end, you'll have a production-ready appointment scheduler with automated SMS notifications.
This guide provides a complete walkthrough for building a web application using the RedwoodJS framework that enables users to book appointments and receive SMS reminders via the Vonage Messages API. We'll cover everything from project setup and core feature implementation to deployment and troubleshooting.
By the end of this tutorial, you'll have a functional RedwoodJS application featuring:
Target Audience: Developers familiar with JavaScript and Node.js, with some exposure to React and database concepts. Prior RedwoodJS experience is helpful but not strictly required.
Technologies Used:
@vonage/server-sdkv3.24.1).[4]Project Overview and Goals
Business Context: No-shows cost service businesses an estimated $150 billion annually in the US alone. Automated appointment reminders can reduce no-shows by 20-30%, directly impacting revenue and operational efficiency. This system addresses the core problem: ensuring customers remember their commitments while minimizing manual administrative overhead.
We aim to build an application that solves the common problem of scheduling appointments and reducing no-shows through automated reminders with minimal human intervention.
Core Features:
System Architecture:
cron) runs periodically to trigger a Redwood Function (/api/sendReminders) which checks for upcoming appointments and triggers reminder SMS via the Vonage API.Prerequisites:
yarncommands, butnpmequivalents generally work.1. Setting Up the RedwoodJS Project
Let's initialize our RedwoodJS application and configure the basic structure.
Create RedwoodJS App: Open your terminal and run:
yarn create redwood-app ./vonage-scheduler cd vonage-schedulerFollow the prompts. Choose TypeScript if you prefer, though this guide uses JavaScript.
Common installation issues:
PORT=3000in.envyarn --version)node --versionInstall Dependencies: We need the Vonage Server SDK v3.24.1,
uuid, anddate-fns.[4]Note:
node-cronis removed as the scheduling logic relies on an external trigger for the function, not an in-process cron scheduler.Database Setup (Prisma): RedwoodJS uses Prisma v6.16.0 for database interaction.[3]
api/db/schema.prisma.datasource dbblock for your chosen database. For PostgreSQL with timezone support:Define Database Schema: Add the
Appointmentmodel toapi/db/schema.prisma:Field explanations:
slotDateTime: Stores the exact date and time using PostgreSQL'sTIMESTAMPTZ(6)type, which stores timestamps in UTC with timezone awareness. This prevents timezone-related bugs in distributed systems.[9]phoneNumber: Stores the recipient number for SMS in E.164 format (+[country code][number], max 15 digits). E.164 is the international standard required by Vonage APIs.[8]bookingCode: A unique identifier generated during booking (8-character UUID substring).confirmed,reminderSent: Boolean flags to track SMS delivery status.slotDateTimeaccelerates booking availability checks. The composite index(reminderSent, confirmed, slotDateTime)optimizes the reminder query that filters by status flags and time range.[9]Create and Apply Migration: This command creates SQL migration files based on your schema changes and applies them to your database.
Enter a name for the migration when prompted (e.g.,
add_appointment_model).Environment Variables (.env): RedwoodJS uses a
.envfile at the project root for environment variables. Create it if it doesn't exist and add your database connection string and Vonage credentials. Consider using.env.defaultsfor non-secret default values likeAPPOINTMENT_REMINDER_MINUTES_BEFORE.Security best practices for credentials:[10][11]
.envto.gitignore(RedwoodJS does this by default).Initialize Vonage Client (API Side): Create a utility file to initialize the Vonage client instance using the v3.24.1 SDK.[4]
// api/src/lib/vonage.js import { Vonage } from '@vonage/server-sdk' import { SMS } from '@vonage/messages' import { logger } from 'src/lib/logger' // Redwood's built-in logger let vonageInstance export const getVonageClient = () => { if (vonageInstance) { return vonageInstance } const apiKey = process.env.VONAGE_API_KEY const apiSecret = process.env.VONAGE_API_SECRET if (!apiKey || !apiSecret) { logger.error('Vonage API Key or Secret is missing in environment variables.') // In production, throw an error to prevent startup without credentials throw new Error('Vonage credentials not configured.') } try { // Using v3.24.1 SDK initialization vonageInstance = new Vonage({ apiKey: apiKey, apiSecret: apiSecret, // applicationId: 'YOUR_VONAGE_APPLICATION_ID', // Optional: Needed for Voice/Video APIs // privateKey: './private.key', // Optional: Needed for JWT-authenticated APIs }) logger.info('Vonage client initialized successfully (v3.24.1 SDK).') return vonageInstance } catch (error) { logger.error({ error }, 'Failed to initialize Vonage client') throw error // Re-throw error to prevent silent failures } } // Helper function for sending SMS using Vonage Messages API (v3 SDK) // Returns { success: boolean, messageId?: string, error?: string, details?: any } export const sendSms = async (to, text) => { const vonage = getVonageClient() // Throws if not configured const from = process.env.VONAGE_FROM_NUMBER if (!from || !to || !text) { const errorMsg = 'Cannot send SMS. Missing "from", "to", or "text".' logger.error(errorMsg) return { success: false, error: errorMsg } } // E.164 format validation: + optional, then 1-9, then 1-14 more digits [8] // Max length: 15 digits total. Country code: 1-3 digits. Subscriber number: remainder. if (!/^\+?[1-9]\d{1,14}$/.test(to) || !/^\+?[1-9]\d{1,14}$/.test(from)) { const errorMsg = `Invalid phone number format. 'to' or 'from' must be E.164 format. To: ${to}, From: ${from}` logger.error(errorMsg) return { success: false, error: errorMsg } } logger.info({ to, from }, 'Attempting to send SMS via Vonage Messages API') try { // Using v3 SDK Messages API // Vonage SDK includes automatic retry for transient network errors (3 attempts by default) const response = await vonage.messages.send(new SMS(text, to, from)) if (response.messages && response.messages[0]) { const messageId = response.messages[0]['message-id'] logger.info({ messageId, to }, 'SMS sent successfully') return { success: true, messageId } } else { logger.warn({ response }, 'SMS sent but unexpected response format') return { success: true, details: response } } } catch (error) { // Vonage API errors: insufficient balance (402), invalid number (422), rate limit (429) logger.error({ error, to, errorCode: error.statusCode }, 'Failed to send SMS via Vonage') return { success: false, error: error.message || 'Unknown error', details: error } } }Error handling notes:
sendSmsreturns a consistent{ success: boolean, ... }object for graceful error handling.2. Implementing Core Functionality (Booking)
Now, let's build the GraphQL API and the service logic for creating appointments.
Generate SDL and Service: Redwood's generators scaffold the necessary files.
yarn rw g sdl Appointment --crud # This creates api/src/graphql/appointments.sdl.js and api/src/services/appointments/appointments.js # It also generates basic CRUD operations. We will modify createAppointment.Define GraphQL Schema (SDL): Modify the generated
appointments.sdl.js(or.graphqlfile if preferred) to include a specific input type for creation and define thecreateAppointmentmutation.# api/src/graphql/appointments.sdl.js or api/src/graphql/appointments.sdl.ts # If using .sdl.js/ts, ensure gql import is present: # import gql from 'graphql-tag' export const schema = gql` type Appointment { id: Int! slotDateTime: DateTime! phoneNumber: String! bookingCode: String! confirmed: Boolean! reminderSent: Boolean! createdAt: DateTime! updatedAt: DateTime! } # Input type for creating appointments input CreateAppointmentInput { slotDateTime: DateTime! # Expecting ISO 8601 String (UTC recommended) phoneNumber: String! # E.164 format strongly recommended } type Query { # Example query (optional for this guide) appointments: [Appointment!]! @requireAuth } type Mutation { # Mutation to create a new appointment createAppointment(input: CreateAppointmentInput!): Appointment! @skipAuth # We keep other generated mutations for now, but focus on create # updateAppointment(id: Int!, input: UpdateAppointmentInput!): Appointment! @requireAuth # deleteAppointment(id: Int!): Appointment! @requireAuth } `CreateAppointmentInput: Defines the data needed from the client (web side).createAppointment: The mutation the web side will call.@skipAuth: For simplicity in this guide, we bypass authentication. In a real app, you'd use@requireAuthand implement Redwood Auth (yarn rw setup auth ...).Queryblock, although not the focus here.Implement the Service Logic: Update the
createAppointmentfunction inapi/src/services/appointments/appointments.js.// api/src/services/appointments/appointments.js import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' import { sendSms } from 'src/lib/vonage' // Import our updated helper import { v4 as uuidv4 } from 'uuid' // For generating booking codes import { UserInputError } from '@redwoodjs/graphql-server' // For validation errors // Recommended: Add robust phone number validation // import { parsePhoneNumberFromString } from 'libphonenumber-js' export const appointments = () => { // Example implementation for the Query defined in SDL // Ensure proper authorization if using @requireAuth // import { requireAuth } from 'src/lib/auth' // requireAuth() return db.appointment.findMany() } export const createAppointment = async ({ input }) => { logger.info({ input }, 'Received request to create appointment') const { slotDateTime, phoneNumber } = input // --- 1. Input Validation --- if (!slotDateTime || !phoneNumber) { throw new UserInputError('Missing required fields: slotDateTime and phoneNumber') } // **Robust Phone Number Validation (Recommended)** // Use a library like libphonenumber-js for proper E.164 validation & formatting // yarn workspace api add libphonenumber-js // const parsedNumber = parsePhoneNumberFromString(phoneNumber) // if (!parsedNumber || !parsedNumber.isValid()) { // throw new UserInputError('Invalid phone number format. Please use E.164 format (e.g., +15551234567).') // } // const formattedPhoneNumber = parsedNumber.format('E.164') // Store normalized format // Basic validation (Use libphonenumber-js in production!) if (!/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) { throw new UserInputError('Invalid phone number format. Please use E.164 format (e.g., +15551234567).') } const formattedPhoneNumber = phoneNumber // Use validated/formatted number later let appointmentTime try { appointmentTime = new Date(slotDateTime) if (isNaN(appointmentTime.getTime())) { throw new Error('Invalid date format') } } catch (e) { throw new UserInputError('Invalid date format for slotDateTime. Please use ISO 8601 format.') } const now = new Date() // Ensure appointment is in the future (minimum 1 hour buffer) const minBookingTime = new Date(now.getTime() + 60 * 60 * 1000) // 1 hour from now if (appointmentTime <= minBookingTime) { throw new UserInputError('Appointment must be at least 1 hour in the future.') } // --- 2. Check Availability with Transaction for Race Condition Prevention --- // Prisma interactive transactions prevent race conditions in high-traffic scenarios // Alternative: Add a unique constraint on slotDateTime in schema for database-level enforcement try { const result = await db.$transaction(async (prisma) => { const existingAppointment = await prisma.appointment.findFirst({ where: { slotDateTime: appointmentTime }, }) if (existingAppointment) { throw new UserInputError('This appointment slot is already booked.') } // --- 3. Generate Booking Code --- // Using 8-char UUID substring: ~16^8 = 4.3B combinations // Collision probability acceptable for moderate volume (<100k appointments) // For high volume, use full UUID or database sequence const bookingCode = uuidv4().substring(0, 8).toUpperCase() // --- 4. Save to Database --- const newAppointment = await prisma.appointment.create({ data: { slotDateTime: appointmentTime, // Stored as UTC in TIMESTAMPTZ field phoneNumber: formattedPhoneNumber, bookingCode: bookingCode, confirmed: false, // Mark as not confirmed until SMS succeeds reminderSent: false, }, }) return newAppointment }) logger.info({ appointmentId: result.id, bookingCode: result.bookingCode }, 'Appointment saved to database') // --- 5. Send Confirmation SMS --- // Format time in user-friendly way (could accept timezone from client for localization) const confirmationText = `Your appointment is booked for ${result.slotDateTime.toLocaleString()}. Your booking code: ${result.bookingCode}.` const smsResult = await sendSms(result.phoneNumber, confirmationText) // --- 6. Update Confirmation Status Based on SMS Result --- if (smsResult.success) { logger.info({ appointmentId: result.id, messageId: smsResult.messageId }, 'Confirmation SMS sent successfully.') try { const updatedAppt = await db.appointment.update({ where: { id: result.id }, data: { confirmed: true }, }) result.confirmed = updatedAppt.confirmed logger.info({ appointmentId: result.id }, 'Appointment confirmed status updated in DB.') } catch (updateError) { logger.error({ updateError, appointmentId: result.id }, 'Failed to update confirmation status after SMS success.') } } else { logger.error({ appointmentId: result.id, error: smsResult.error }, 'Failed to send confirmation SMS.') // Options: 1) Return with confirmed:false (current), 2) Add to retry queue, 3) Delete appointment } return result } catch (error) { // Handle Prisma transaction errors and validation errors if (error instanceof UserInputError) { throw error } if (error.code === 'P2002' && error.meta?.target?.includes('bookingCode')) { logger.error('Booking code collision detected (very rare). Retry recommended.') throw new Error('Failed to generate unique booking code. Please try again.') } logger.error({ error }, 'Failed to create appointment') throw new Error('Failed to save appointment. Please try again.') } }Race condition handling:
$transactionto wrap availability check and creation in a single database transaction.@@unique([slotDateTime])constraint in Prisma schema for database-level enforcement.Error response structure for clients:
UserInputError: GraphQL returns400with{ errors: [{ message: "...", extensions: { code: "BAD_USER_INPUT" } }] }Error: Returns500with{ errors: [{ message: "..." }] }error.graphQLErrors[0].extensions.codeto distinguish user errors from server errors.3. Building the Frontend (Web Side)
Let's create a simple React page with a form to book appointments.
Generate Page:
yarn rw g page AppointmentBooking /book # Creates web/src/pages/AppointmentBookingPage/AppointmentBookingPage.js (and test/route files)Create the Form Component: Modify
web/src/pages/AppointmentBookingPage/AppointmentBookingPage.js.// web/src/pages/AppointmentBookingPage/AppointmentBookingPage.js import { useState } from 'react' import { MetaTags, useMutation } from '@redwoodjs/web' import { toast, Toaster } from '@redwoodjs/web/toast' // For notifications import { Form, TextField, DatetimeLocalField, Submit, Label, FieldError } from '@redwoodjs/forms' import gql from 'graphql-tag' // GraphQL Mutation matching the one defined in the SDL const CREATE_APPOINTMENT_MUTATION = gql` mutation CreateAppointmentMutation($input: CreateAppointmentInput!) { createAppointment(input: $input) { id slotDateTime phoneNumber bookingCode confirmed } } ` const AppointmentBookingPage = () => { const [formKey, setFormKey] = useState(Date.now()) // Used to reset form const [createAppointment, { loading, error }] = useMutation( CREATE_APPOINTMENT_MUTATION, { onCompleted: (data) => { if (data.createAppointment.confirmed) { toast.success( `Appointment booked successfully! Code: ${data.createAppointment.bookingCode}. Check your phone for confirmation.`, { duration: 8000 } ) } else { // Use warning toast if confirmation failed but booking succeeded toast.warn( `Appointment booked (Code: ${data.createAppointment.bookingCode}), but confirmation SMS failed. Please contact support.`, { duration: 10000 } ) } // Reset form by changing the key prop setFormKey(Date.now()) }, onError: (error) => { // Check for user input errors vs server errors const isUserError = error.graphQLErrors?.some( err => err.extensions?.code === 'BAD_USER_INPUT' ) if (isUserError) { toast.error(`Booking failed: ${error.message}`) } else { toast.error('Server error. Please try again or contact support.') } }, } ) const onSubmit = (formData) => { // Convert datetime-local (browser local time) to ISO 8601 UTC string const input = { slotDateTime: new Date(formData.slotDateTime).toISOString(), phoneNumber: formData.phoneNumber, } createAppointment({ variables: { input } }) } // Tailwind CSS classes const labelStyle = "block text-sm font-medium text-gray-700 mb-1" const inputStyle = "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" const errorStyle = "mt-1 text-xs text-red-600" // Calculate minimum datetime: 1 hour from now const getMinDateTime = () => { const now = new Date() now.setHours(now.getHours() + 1) return now.toISOString().slice(0, 16) // Format: YYYY-MM-DDTHH:mm } return ( <> <MetaTags title="Book Appointment" description="Book your appointment slot" /> <Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} /> <div className="max-w-md mx-auto mt-8 p-6 bg-white rounded-lg shadow-md" role="main"> <h1 className="text-2xl font-semibold mb-4 text-center">Book Your Appointment</h1> <Form key={formKey} onSubmit={onSubmit} config={{ mode: 'onBlur' }}> <div className="mb-4"> <Label name="slotDateTime" className={labelStyle} errorClassName={`${labelStyle} text-red-700`} > Choose date and time: </Label> <DatetimeLocalField name="slotDateTime" className={inputStyle} errorClassName={`${inputStyle} border-red-500`} validation={{ required: 'Appointment date and time is required', valueAsDate: true }} min={getMinDateTime()} aria-describedby="datetime-help" /> <p id="datetime-help" className="mt-1 text-xs text-gray-500"> Must be at least 1 hour in the future </p> <FieldError name="slotDateTime" className={errorStyle} /> </div> <div className="mb-4"> <Label name="phoneNumber" className={labelStyle} errorClassName={`${labelStyle} text-red-700`} > Phone number: </Label> <TextField name="phoneNumber" className={inputStyle} errorClassName={`${inputStyle} border-red-500`} validation={{ required: 'Phone number is required', pattern: { value: /^\+?[1-9]\d{1,14}$/, message: 'Use E.164 format (e.g., +15551234567)' } }} placeholder="+15551234567" aria-describedby="phone-help" /> <p id="phone-help" className="mt-1 text-xs text-gray-500"> International format: +[country code][number] </p> <FieldError name="phoneNumber" className={errorStyle} /> </div> {error && ( <div className="mb-4 p-3 bg-red-100 text-red-700 rounded" role="alert"> Error: {error.message} </div> )} <Submit disabled={loading} className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors" aria-label={loading ? 'Booking in progress' : 'Book appointment'} > {loading ? 'Booking...' : 'Book Appointment'} </Submit> </Form> </div> </> ) } export default AppointmentBookingPageForm validation strategy:
onBlurmode validates fields when user leaves the field.Accessibility considerations:
role="main"landmark for main content.aria-describedbylinks help text to form fields.aria-labelprovides context for submit button states.role="alert"announces errors to screen readers.Run the Development Server:
Navigate to
http://localhost:8910/book(or the port specified). You should see the form. Try booking an appointment. Check your terminal logs (apiside) and your phone for the SMS confirmation! Check the database to see theconfirmedflag.4. Implementing Scheduled Reminders
We need a mechanism to periodically check for upcoming appointments and send reminders. We'll use a RedwoodJS Function triggered by an external scheduler. Running cron within a serverless function is unreliable because serverless instances are ephemeral.
Scheduling Approaches Comparison:
Recommended: Use platform-native solutions (Vercel Cron or Netlify Scheduled Functions) for simplicity, or system cron for maximum reliability.[6][7]
Create a Redwood Function:
yarn rw g function sendReminders # Creates api/src/functions/sendReminders.jsImplement the Function Logic: Edit
api/src/functions/sendReminders.js:// api/src/functions/sendReminders/sendReminders.js import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' import { sendSms } from 'src/lib/vonage' import { add } from 'date-fns' export const handler = async (event, _context) => { logger.info('sendReminders function triggered') // Security: Verify cron secret to prevent unauthorized invocations const cronSecret = process.env.CRON_SECRET if (cronSecret) { const providedSecret = event.headers['x-cron-secret'] || event.queryStringParameters?.secret if (providedSecret !== cronSecret) { logger.warn('Unauthorized attempt to trigger sendReminders function') return { statusCode: 401, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'Unauthorized' }), } } } try { const reminderMinutes = parseInt(process.env.APPOINTMENT_REMINDER_MINUTES_BEFORE || '60', 10) const now = new Date() const reminderWindow = add(now, { minutes: reminderMinutes }) // Find appointments needing reminders: not sent, confirmed, within reminder window const appointmentsToRemind = await db.appointment.findMany({ where: { reminderSent: false, confirmed: true, slotDateTime: { lte: reminderWindow, gte: now, }, }, }) logger.info(`Found ${appointmentsToRemind.length} appointments needing reminders`) let successCount = 0 let failureCount = 0 const failedAppointments = [] // Process reminders sequentially to avoid overwhelming SMS API rate limits // For high volume (>100 reminders), implement batching with delays for (const appointment of appointmentsToRemind) { const reminderText = `Reminder: Your appointment is scheduled for ${appointment.slotDateTime.toLocaleString()}. Booking code: ${appointment.bookingCode}` const smsResult = await sendSms(appointment.phoneNumber, reminderText) if (smsResult.success) { // Mark as sent immediately to prevent duplicate sends if function times out await db.appointment.update({ where: { id: appointment.id }, data: { reminderSent: true }, }) successCount++ logger.info({ appointmentId: appointment.id }, 'Reminder sent successfully') } else { failureCount++ failedAppointments.push({ id: appointment.id, error: smsResult.error }) logger.error({ appointmentId: appointment.id, error: smsResult.error }, 'Failed to send reminder') // Retry strategy: Failed reminders remain with reminderSent:false // Next cron run will retry (idempotent) } } const response = { message: 'Reminder processing complete', total: appointmentsToRemind.length, success: successCount, failed: failureCount, failedAppointments: failedAppointments.length > 0 ? failedAppointments : undefined, } return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(response), } } catch (error) { logger.error({ error }, 'Error in sendReminders function') return { statusCode: 500, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'Internal server error', details: error.message }), } } }Retry logic and failure recovery:
reminderSent: false, so next cron run retries automatically (idempotent).reminderSent: trueimmediately to prevent duplicates if function times out.await new Promise(r => setTimeout(r, 100))between sends.Configure External Scheduler:
Option 1: Vercel Cron Jobs[6]
Create or edit
vercel.jsonin project root:{ "crons": [{ "path": "/.redwood/functions/sendReminders?secret=YOUR_CRON_SECRET", "schedule": "*/15 * * * *" }] }schedule: Cron expression.*/15 * * * *= every 15 minutes.Vercel Setup Steps:
yarn rw setup deploy vercelthenyarn rw deploy vercelCRON_SECRETenvironment variable in Vercel dashboard (Settings → Environment Variables)vercel.jsonto project root and redeployOption 2: Netlify Scheduled Functions[7]
Edit
api/src/functions/sendReminders.jsto add config export:// At the top of api/src/functions/sendReminders.js import type { Config } from "@netlify/functions" export const config: Config = { schedule: "@hourly" // Or "*/15 * * * *" for every 15 minutes } // ... rest of handler codeAlternatively, use
netlify.toml:# netlify.toml [functions."sendReminders"] schedule = "*/15 * * * *"Netlify Setup Steps:
yarn rw setup deploy netlifythenyarn rw deploy netlifyCRON_SECRETenvironment variable in Netlify dashboard (Site settings → Environment variables)Option 3: System Cron (Linux/macOS)
Edit crontab:
crontab -e# Send reminders every 15 minutes */15 * * * * curl -X GET https://your-app.com/.redwood/functions/sendReminders -H "x-cron-secret: YOUR_CRON_SECRET" >> /var/log/appointment-reminders.log 2>&1@rebootentry to ensure cron survives server restartstail -f /var/log/appointment-reminders.logOption 4: GitHub Actions (for testing/open-source)
Create
.github/workflows/send-reminders.yml:name: Send Appointment Reminders on: schedule: - cron: '*/15 * * * *' # Every 15 minutes workflow_dispatch: # Allow manual trigger jobs: send-reminders: runs-on: ubuntu-latest steps: - name: Trigger reminder function run: | curl -X GET https://your-app.com/.redwood/functions/sendReminders \ -H "x-cron-secret: ${{ secrets.CRON_SECRET }}"5. Deployment Considerations
Deploy your RedwoodJS application to platforms like Vercel, Netlify, or Render.
Key Environment Variables:
DATABASE_URL: Production database connection string (use connection pooling for serverless: PgBouncer, Supabase, or Neon)VONAGE_API_KEY: Your Vonage API keyVONAGE_API_SECRET: Your Vonage API secretVONAGE_FROM_NUMBER: Your Vonage virtual number (E.164 format)APPOINTMENT_REMINDER_MINUTES_BEFORE: Reminder timing (default: 60)CRON_SECRET: Secret for securing cron endpoint (generate withopenssl rand -hex 32)Database Migrations: Run migrations in production:
Platform-Specific Deployment Guides:
Vercel Deployment:
yarn rw setup deploy vercelnpm i -g vercelyarn rw deploy vercelvercel.jsonincludes cron configuration (see Section 4)https://your-app.vercel.appNetlify Deployment:
yarn rw setup deploy netlifynpm i -g netlify-cliyarn rw deploy netlifyhttps://your-app.netlify.appRender Deployment:
yarn install && yarn rw buildyarn rw serveSecurity Best Practices:[10][11]
Never commit
.envfiles: Ensure.gitignoreincludes.env(RedwoodJS default)Environment-specific configurations: Use different API keys for dev, staging, production
Secrets management services: For production, migrate from environment variables to dedicated secrets managers:
Example with 1Password CLI:
# .env references 1Password VONAGE_API_KEY="op://Vault/Vonage/api_key" # Start app with secrets injection op run -- yarn rw devImplement rate limiting: Use RedwoodJS middleware or API gateway (Cloudflare, AWS API Gateway) to limit requests/IP
Add authentication:
yarn rw setup auththen choose provider (Auth0, Supabase, Clerk). Replace@skipAuthwith@requireAuthInput validation: Always validate on server-side. Client validation is convenience only.
HTTPS in production: Enforce HTTPS redirects (Vercel/Netlify automatic, Render: add HTTPS redirect rule)
Credential rotation: Plan quarterly rotation of Vonage API keys and database passwords
Monitor logs: Never log full
process.envor secrets. Redwood's logger redacts sensitive fields by default.CRON_SECRET security: Treat as sensitive. Rotate if exposed. Consider IP allowlisting if possible.
6. Testing Your Application
Test the Booking Flow:
/bookin your browserconfirmed: trueif SMS was successfulTest the Reminder System:
APPOINTMENT_REMINDER_MINUTES_BEFOREto 5 minutes for quick testing)# Local testing curl -X GET http://localhost:8911/.redwood/functions/sendReminders -H "x-cron-secret: YOUR_CRON_SECRET" # Production testing curl -X GET https://your-app.com/.redwood/functions/sendReminders -H "x-cron-secret: YOUR_CRON_SECRET"reminderSent: truein databaseAutomated Testing Strategy:
RedwoodJS includes Jest for unit and integration testing.
Service Tests (
api/src/services/appointments/appointments.test.js):import { db } from 'src/lib/db' import { createAppointment } from './appointments' // Mock Vonage SMS jest.mock('src/lib/vonage', () => ({ sendSms: jest.fn(() => Promise.resolve({ success: true, messageId: 'test-123' })) })) describe('createAppointment', () => { scenario('creates appointment with valid data', async (scenario) => { const futureDate = new Date(Date.now() + 2 * 60 * 60 * 1000) // 2 hours ahead const input = { slotDateTime: futureDate.toISOString(), phoneNumber: '+15551234567' } const result = await createAppointment({ input }) expect(result.phoneNumber).toBe('+15551234567') expect(result.confirmed).toBe(true) expect(result.bookingCode).toHaveLength(8) }) scenario('rejects duplicate time slots', async () => { const futureDate = new Date(Date.now() + 2 * 60 * 60 * 1000) await createAppointment({ input: { slotDateTime: futureDate.toISOString(), phoneNumber: '+15551234567' } }) await expect( createAppointment({ input: { slotDateTime: futureDate.toISOString(), phoneNumber: '+15559876543' } }) ).rejects.toThrow('already booked') }) scenario('rejects past appointments', async () => { const pastDate = new Date(Date.now() - 1000) await expect( createAppointment({ input: { slotDateTime: pastDate.toISOString(), phoneNumber: '+15551234567' } }) ).rejects.toThrow('must be at least 1 hour in the future') }) scenario('rejects invalid phone numbers', async () => { const futureDate = new Date(Date.now() + 2 * 60 * 60 * 1000) await expect( createAppointment({ input: { slotDateTime: futureDate.toISOString(), phoneNumber: '555-1234' } }) ).rejects.toThrow('E.164 format') }) })Run tests:
yarn rw test apiCommon Test Scenarios:
confirmed: trueconfirmed: falseand log errorreminderSent: trueafter success7. Troubleshooting Common Issues
SMS Not Sending:
VONAGE_API_KEY,VONAGE_API_SECRET, andVONAGE_FROM_NUMBERin.env# Test credentials node -e "console.log(process.env.VONAGE_API_KEY)"+[country][number], no spaces). Test with regex:^\+?[1-9]\d{1,14}$[8]0: Success1: Throttled (rate limit)2: Missing parameters3: Invalid parameters4: Invalid credentials5: Internal error6: Invalid message7: Number barred8: Partner account barred9: Partner quota exceededDatabase Connection Errors:
DATABASE_URLformat# PostgreSQL format postgresql://user:password@host:5432/database?schema=public # Test connection yarn rw prisma db push# macOS brew services list | grep postgresql # Linux systemctl status postgresqlyarn rw prisma migrate dev # Or reset for clean slate (DELETES ALL DATA) yarn rw prisma migrate resetReminder Function Not Triggering:
vercel.jsonornetlify.tomlsyntax# Test cron expression at crontab.guru */15 * * * * # Every 15 minutes# Check environment variable echo $CRON_SECRET # Test manually curl -v https://your-app.com/.redwood/functions/sendReminders -H "x-cron-secret: YOUR_SECRET"tail -f /var/log/cron.logAPPOINTMENT_REMINDER_MINUTES_BEFOREcalculation// Debug in function logger.info({ now, reminderWindow, reminderMinutes }, 'Reminder window calculated')yarn rw prisma studio # Filter: reminderSent = false, confirmed = true, slotDateTime within windowGraphQL Mutation Failures:
appointments.sdl.jstypes match service return valuesyarn rw dev # Open GraphQL Playground: http://localhost:8911/graphql# API logs show in terminal running `yarn rw dev`mutation CreateTest { createAppointment(input: { slotDateTime: "2025-10-15T14:00:00Z" phoneNumber: "+15551234567" }) { id bookingCode confirmed } }api/src/functions/graphql.jsFrequently Asked Questions
How do I change the reminder timing for appointments?
Adjust the
APPOINTMENT_REMINDER_MINUTES_BEFOREenvironment variable in your.envfile (or hosting platform environment config). Set it to60for 1 hour,1440for 24 hours, or any value in minutes. ThesendRemindersfunction uses this value to calculate the reminder window:// Reminder sent when: now < appointment_time <= (now + APPOINTMENT_REMINDER_MINUTES_BEFORE) const reminderWindow = add(now, { minutes: parseInt(process.env.APPOINTMENT_REMINDER_MINUTES_BEFORE) })For multiple reminder times (e.g., 24 hours AND 1 hour before), add a
remindersSentJSON field to track which reminders were sent, and modify the function logic accordingly.Can I send reminders through WhatsApp instead of SMS?
Yes. Vonage supports WhatsApp messaging through the Messages API. Modifications required:[4]
sendSmsfunction:import { WhatsApp } from '@vonage/messages' // Instead of: new SMS(text, to, from) const message = new WhatsApp({ to: to, from: from, // Your WhatsApp Business Number message_type: 'text', text: text }) const response = await vonage.messages.send(message)Reference: Vonage WhatsApp API Documentation
How do I prevent double-booking appointments?
The
createAppointmentservice uses Prisma transactions to prevent race conditions:[9]await db.$transaction(async (prisma) => { const existingAppointment = await prisma.appointment.findFirst({ where: { slotDateTime: appointmentTime }, }) if (existingAppointment) throw new UserInputError('Slot already booked') const newAppointment = await prisma.appointment.create({ data: {...} }) return newAppointment })Alternative: Database-level constraint:
Then run migration:
yarn rw prisma migrate devThe database constraint is more robust for high-traffic scenarios, but throws less user-friendly errors.
What phone number format should users enter?
Users should enter phone numbers in E.164 format:
+[country code][number]with no spaces or special characters.[8]Examples:
+14155551234(country code 1, area code 415, number 5551234)+442071234567(country code 44, area code 20, number 71234567)+34912345678(country code 34, area code 91, number 2345678)E.164 specification:
+(optional in code, required by Vonage)Recommended: Use
libphonenumber-jsfor robust validation:import { parsePhoneNumberFromString } from 'libphonenumber-js' const parsedNumber = parsePhoneNumberFromString(phoneNumber, 'US') // Default country hint if (!parsedNumber || !parsedNumber.isValid()) { throw new UserInputError('Invalid phone number') } const formattedPhoneNumber = parsedNumber.format('E.164') // Normalize to +14155551234Client-side: Add country selector dropdown with
react-phone-number-inputfor better UX.How do I add user authentication to the booking system?
RedwoodJS supports multiple authentication providers through RedwoodJS Auth:[1]
Setup steps:
# Choose a provider: dbAuth (built-in), Auth0, Supabase, Clerk, Firebase, etc. yarn rw setup auth dbAuth # Generates: auth functions, login/signup pages, database migrationsUpdate SDL to require auth:
type Mutation { createAppointment(input: CreateAppointmentInput!): Appointment! @requireAuth # Changed from @skipAuth }Use auth in services:
import { requireAuth } from 'src/lib/auth' export const createAppointment = async ({ input }) => { requireAuth() // Throws error if not authenticated const userId = context.currentUser.id // Access logged-in user // Associate appointment with user const newAppointment = await db.appointment.create({ data: { ...input, userId: userId // Add userId field to schema } }) }Frontend: Use
useAuthhook:import { useAuth } from '@redwoodjs/auth' const { isAuthenticated, currentUser, logIn, logOut } = useAuth() if (!isAuthenticated) { return <Redirect to="/login" /> }For user-specific appointment views, add
userIdtoAppointmentmodel and filter queries bycurrentUser.id.Can I customize the SMS message content?
Yes. Modify the
confirmationTextandreminderTextin your code:Confirmation SMS (in
appointments.jsservice):const confirmationText = `✓ Appointment confirmed for ${result.slotDateTime.toLocaleString()}. Code: ${result.bookingCode}. Reply CANCEL to cancel.`Reminder SMS (in
sendReminders.jsfunction):const reminderText = `⏰ Reminder: Appointment in ${reminderMinutes} minutes (${appointment.slotDateTime.toLocaleString()}). Code: ${appointment.bookingCode}. See you soon!`Best practices:
How do I handle different time zones for appointments?
Best practice: Store in UTC, display in local time.[9]
Database storage:
Client-side: Convert to local time:
// Option 1: Native JavaScript const localTime = new Date(appointment.slotDateTime).toLocaleString('en-US', { timeZone: 'America/New_York', // User's timezone from client or profile dateStyle: 'medium', timeStyle: 'short' }) // Option 2: date-fns-tz library import { formatInTimeZone } from 'date-fns-tz' const localTime = formatInTimeZone( appointment.slotDateTime, 'America/New_York', 'MMM d, yyyy h:mm a zzz' ) // Output: "Oct 15, 2025 2:00 PM EDT"Server-side: Accept timezone from client:
// Modified input schema input CreateAppointmentInput { slotDateTime: DateTime! phoneNumber: String! timezone: String # e.g., "America/New_York" (IANA timezone) } // Service: Store timezone for SMS formatting const newAppointment = await db.appointment.create({ data: { slotDateTime: appointmentTime, // Prisma converts to UTC timezone: input.timezone || 'UTC', // Add timezone field to schema ... } }) // SMS: Format in user's timezone import { formatInTimeZone } from 'date-fns-tz' const localTimeStr = formatInTimeZone( appointment.slotDateTime, appointment.timezone, 'MMM d, yyyy h:mm a zzz' ) const reminderText = `Reminder: Appointment at ${localTimeStr}. Code: ${appointment.bookingCode}`Key principles:
DateTimewith@db.Timestamptzdoes this automatically)Intl.DateTimeFormat().resolvedOptions().timeZone)What happens if SMS delivery fails?
The
sendSmsfunction returns a{ success: boolean }object. On failure:Booking confirmation failure:
confirmed: falseconfirmed: falseand contact users manuallyReminder failure:
reminderSentremainsfalseImplementing a retry queue with RedwoodJS Background Jobs:[1]
yarn rw setup jobs// api/src/jobs/SendReminderJob/SendReminderJob.js export const SendReminderJob = async ({ appointmentId }) => { const appointment = await db.appointment.findUnique({ where: { id: appointmentId } }) const result = await sendSms(appointment.phoneNumber, `Reminder: ...`) if (result.success) { await db.appointment.update({ where: { id: appointmentId }, data: { reminderSent: true } }) } else { throw new Error('SMS failed') // Job system will retry with exponential backoff } } // In sendReminders function: Queue failed reminders if (!smsResult.success) { await scheduleJob('SendReminderJob', { appointmentId: appointment.id }, { runAt: new Date(Date.now() + 5 * 60 * 1000), // Retry in 5 minutes maxAttempts: 3 }) }Always log failures for monitoring and debugging:
logger.error({ appointmentId, phoneNumber, error: smsResult.error, vonageErrorCode: smsResult.details?.statusCode }, 'SMS delivery failed')Set up alerts (Sentry, Datadog, or simple email on error) to notify admins of persistent failures.
How much does it cost to send SMS through Vonage?
Vonage pricing is per-message, varying by destination country:[4]
Common rates (as of 2025):
Multi-part messages:
Cost estimation:
New accounts: Receive free trial credits ($2-10 depending on region) for testing.
Cost optimization:
Check current rates: Vonage SMS Pricing
How do I deploy this application to production?
Recommended platforms for RedwoodJS:
1. Vercel (Easiest):
# Setup yarn rw setup deploy vercel # Deploy yarn rw deploy vercel --prodvercel.json(see Section 4)2. Netlify:
# Setup yarn rw setup deploy netlify # Deploy yarn rw deploy netlify --prod3. Render:
yarn install && yarn rw buildyarn rw servePre-deployment checklist:
yarn rw buildlocally to verify build succeedsyarn rw prisma migrate deployafter deploymentcurl https://your-app.com/.redwood/functions/sendRemindersProduction monitoring:
References
[1] RedwoodJS. "Releases." GitHub and RedwoodJS Community. Retrieved from https://github.com/redwoodjs/redwood/releases and https://community.redwoodjs.com/
[2] Node.js Release Working Group. "Node.js Releases." Retrieved from https://nodejs.org/en/about/previous-releases and https://nodesource.com/blog/nodejs-v22-long-term-support-lts
[3] Prisma. "Changelog and Releases." Retrieved from https://www.prisma.io/changelog and https://github.com/prisma/prisma/releases
[4] Vonage. "@vonage/server-sdk." npm. Retrieved from https://www.npmjs.com/package/@vonage/server-sdk and https://developer.vonage.com/
[5] Healthcare IT News. "No-show rates cost healthcare billions annually." 2024. Statistical data on appointment no-show impacts.
[6] Vercel. "Cron Jobs Documentation." Retrieved from https://vercel.com/docs/cron-jobs and https://vercel.com/guides/how-to-setup-cron-jobs-on-vercel
[7] Netlify. "Scheduled Functions Documentation." Retrieved from https://docs.netlify.com/build/functions/scheduled-functions
[8] Vonage. "What Is E.164 Format?" Developer Blog. Retrieved October 2024 from https://developer.vonage.com/en/blog/what-is-e-164-format
[9] Deiaa, Basem. "How to Fix Prisma DateTime and Timezone Issues with PostgreSQL." Medium. September 2025. Retrieved from https://medium.com/@basem.deiaa/how-to-fix-prisma-datetime-and-timezone-issues-with-postgresql-1c778aa2d122
[10] Liran Tal. "Do not use secrets in environment variables." Node.js Security Blog. October 2024. Retrieved from https://nodejs-security.com/blog/do-not-use-secrets-in-environment-variables
[11] FullStack Labs. "Best Practices for Scalable & Secure React + Node.js Apps in 2025." July 2025. Retrieved from https://www.fullstack.com/labs/resources/blog/best-practices-for-scalable-secure-react-node-js-apps-in-2025