code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / RedwoodJS

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.

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 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:

  1. Appointment Booking: Users can view available time slots and book an appointment by providing their phone number.
  2. Instant Confirmation: Upon successful booking, the user receives an immediate SMS confirmation containing the appointment details and a unique cancellation code.
  3. 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.
  4. 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).

System Architecture:

+-------------------+ +-----------------------+ +---------------------+ +--------------------+ | User Browser | <--> | RedwoodJS Web Side | <--> | RedwoodJS API Side | <--> | PostgreSQL Database| | (React UI) | | (React Components, | | (GraphQL, Services, | | (Prisma Client) | +-------------------+ | Apollo Client) | | Functions) | +---------^----------+ +-----------------------+ +---------+-----------+ | | (Vonage SDK v3.24.1) v +--------------------+ | Vonage Messages API| +--------------------+ ^ | (API Call via Function) +---------+-----------+ | External Scheduler | | (e.g., Vercel Cron, | | Netlify Scheduled) | +---------------------+
  • 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.
  • A Vonage API account (Sign up for free credit at https://developer.vonage.com/)
    • 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.

  1. 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
  2. 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.

  3. 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:
      prisma
      // api/db/schema.prisma
      datasource db {
        provider = "postgresql"
        url      = env("DATABASE_URL")
      }
      
      generator client {
        provider      = "prisma-client-js"
        binaryTargets = ["native"]
      }
    • If using SQLite (good for local development):
      prisma
      // api/db/schema.prisma
      datasource db {
        provider = "sqlite"
        url      = env("DATABASE_URL")
      }
      // ... rest of the file
  4. 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]
  5. 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).

  6. 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.

    dotenv
    # .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.
  7. 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.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:

    • 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.

  1. 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.
  2. 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 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 @requireAuth and implement Redwood Auth (yarn rw setup auth ...).
    • Added an example Query block, although not the focus here.
  3. Implement the Service Logic: Update the createAppointment function in api/src/services/appointments/appointments.js.

    javascript
    // 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:

    • 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.

    Error response structure for clients:

    • UserInputError: GraphQL returns 400 with { errors: [{ message: "...", extensions: { code: "BAD_USER_INPUT" } }] }
    • Generic Error: Returns 500 with { errors: [{ message: "..." }] }
    • Client should check error.graphQLErrors[0].extensions.code to 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.

  1. Generate Page:

    bash
    yarn rw g page AppointmentBooking /book
    # Creates web/src/pages/AppointmentBookingPage/AppointmentBookingPage.js (and test/route files)
  2. Create the Form Component: Modify web/src/pages/AppointmentBookingPage/AppointmentBookingPage.js.

    javascript
    // 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 AppointmentBookingPage

    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).
  3. 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:

ApproachProsConsBest For
Vercel Cron JobsBuilt-in, no extra infrastructure, free tier availableVercel-only, limited to production deploymentsVercel-hosted apps
Netlify Scheduled FunctionsNative integration, simple configurationNetlify-only, beta feature, 30s timeoutNetlify-hosted apps
System Cron (Linux/macOS)Full control, reliable, no vendor lock-inRequires server access, manual setupSelf-hosted or VPS deployments
GitHub Actions ScheduleFree for public repos, CI/CD integrationMinimum 5-minute intervals, rate limitsOpen-source projects
RedwoodJS Background JobsNative RedwoodJS feature, built-in schedulingRequires v8.0+, needs dedicated worker processComplex job queues

Recommended: Use platform-native solutions (Vercel Cron or Netlify Scheduled Functions) for simplicity, or system cron for maximum reliability.[6][7]

  1. Create a Redwood Function:

    bash
    yarn rw g function sendReminders
    # Creates api/src/functions/sendReminders.js
  2. Implement the Function Logic: Edit api/src/functions/sendReminders.js:

    javascript
    // 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:

    • 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.
  3. Configure External Scheduler:

    Option 1: Vercel Cron Jobs[6]

    Create or edit vercel.json in project root:

    json
    {
      "crons": [{
        "path": "/.redwood/functions/sendReminders?secret=YOUR_CRON_SECRET",
        "schedule": "*/15 * * * *"
      }]
    }
    • schedule: Cron expression. */15 * * * * = every 15 minutes.
    • Runs only in production. Preview deployments ignore cron jobs.
    • Free tier: Up to 1 cron job, 100 invocations/day.
    • Vercel automatically passes request headers. No additional auth needed if using secret in query parameter.

    Vercel Setup Steps:

    1. Deploy to Vercel: yarn rw setup deploy vercel then yarn rw deploy vercel
    2. Add CRON_SECRET environment variable in Vercel dashboard (Settings → Environment Variables)
    3. Add vercel.json to project root and redeploy
    4. Monitor: Vercel dashboard → Functions → sendReminders → Logs

    Option 2: Netlify Scheduled Functions[7]

    Edit api/src/functions/sendReminders.js to add config export:

    javascript
    // 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 code

    Alternatively, use netlify.toml:

    toml
    # netlify.toml
    [functions."sendReminders"]
    schedule = "*/15 * * * *"

    Netlify Setup Steps:

    1. Deploy to Netlify: yarn rw setup deploy netlify then yarn rw deploy netlify
    2. Add CRON_SECRET environment variable in Netlify dashboard (Site settings → Environment variables)
    3. Scheduled functions only run on published deploys (not deploy previews)
    4. Monitor: Netlify dashboard → Functions → sendReminders → Logs
    5. Limitation: 30-second execution timeout (vs 10s for regular functions)

    Option 3: System Cron (Linux/macOS)

    Edit crontab: crontab -e

    bash
    # 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
    • Requires server with persistent cron daemon
    • Most reliable for production workloads
    • Use @reboot entry to ensure cron survives server restarts
    • Monitor logs: tail -f /var/log/appointment-reminders.log

    Option 4: GitHub Actions (for testing/open-source)

    Create .github/workflows/send-reminders.yml:

    yaml
    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 }}"
    • Minimum interval: 5 minutes
    • May have ~10 minute delay in execution
    • Free for public repositories

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 key
  • VONAGE_API_SECRET: Your Vonage API secret
  • VONAGE_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 with openssl rand -hex 32)

Database Migrations: Run migrations in production:

bash
yarn rw prisma migrate deploy

Platform-Specific Deployment Guides:

Vercel Deployment:

  1. Run setup: yarn rw setup deploy vercel
  2. Install Vercel CLI: npm i -g vercel
  3. Deploy: yarn rw deploy vercel
  4. Add environment variables in Vercel dashboard: Settings → Environment Variables
  5. Ensure vercel.json includes cron configuration (see Section 4)
  6. Redwood auto-configures serverless functions and API routes
  7. Production URL: https://your-app.vercel.app

Netlify Deployment:

  1. Run setup: yarn rw setup deploy netlify
  2. Install Netlify CLI: npm i -g netlify-cli
  3. Deploy: yarn rw deploy netlify
  4. Add environment variables: Site settings → Environment variables → Add a variable
  5. Configure scheduled function (see Section 4)
  6. Netlify auto-deploys from Git on push to main branch
  7. Production URL: https://your-app.netlify.app

Render Deployment:

  1. Create Render account at https://render.com
  2. New → Web Service → Connect repository
  3. Build command: yarn install && yarn rw build
  4. Start command: yarn rw serve
  5. Add environment variables in dashboard
  6. For cron: Create separate "Cron Job" service pointing to sendReminders function URL

Security Best Practices:[10][11]

  • Never commit .env files: Ensure .gitignore includes .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:

    • AWS Secrets Manager: Auto-rotation, audit logs, fine-grained IAM policies
    • Google Secret Manager: Integrated with GCP IAM, versioning, automatic encryption
    • HashiCorp Vault: Self-hosted, dynamic secrets, leasing, revocation
    • 1Password CLI: Developer-friendly, integrates with CI/CD, team vaults
    • Doppler: Modern secrets management SaaS, environment branching

    Example with 1Password CLI:

    bash
    # .env references 1Password
    VONAGE_API_KEY="op://Vault/Vonage/api_key"
    
    # Start app with secrets injection
    op run -- yarn rw dev
  • Implement rate limiting: Use RedwoodJS middleware or API gateway (Cloudflare, AWS API Gateway) to limit requests/IP

  • Add authentication: yarn rw setup auth then choose provider (Auth0, Supabase, Clerk). Replace @skipAuth with @requireAuth

  • Input 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.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:

  1. Navigate to /book in your browser
  2. Select a future date and time (at least 1 hour ahead)
  3. Enter your phone number in E.164 format (e.g., +15551234567)
  4. Submit the form
  5. Check your phone for the confirmation SMS within 5-10 seconds
  6. Verify the appointment appears in your database:
    bash
    yarn rw prisma studio
  7. Check that confirmed: true if SMS was successful

Test the Reminder System:

  1. Create a test appointment 1 hour in the future (or adjust APPOINTMENT_REMINDER_MINUTES_BEFORE to 5 minutes for quick testing)
  2. 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"
  3. Check your phone for the reminder SMS
  4. 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):

javascript
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:

bash
yarn rw test api

Common Test Scenarios:

  • ✅ Booking valid appointments (future time, E.164 number)
  • ❌ Booking past appointments (should fail validation)
  • ❌ Booking duplicate time slots (should fail with UserInputError)
  • ❌ Invalid phone number formats (should fail with validation error)
  • ❌ Missing required fields (should fail)
  • ✅ SMS delivery success updates confirmed: true
  • ❌ SMS delivery failures keep confirmed: false and log error
  • ✅ Reminder function finds and sends to eligible appointments
  • ✅ Reminder function marks reminderSent: true after success

7. Troubleshooting Common Issues

SMS Not Sending:

  • Verify credentials: Check VONAGE_API_KEY, VONAGE_API_SECRET, and VONAGE_FROM_NUMBER in .env
    bash
    # Test credentials
    node -e "console.log(process.env.VONAGE_API_KEY)"
  • Phone number format: Must be E.164 (+[country][number], no spaces). Test with regex: ^\+?[1-9]\d{1,14}$[8]
  • Virtual number capabilities: Verify number has SMS capability in Vonage Dashboard → Numbers
  • API errors: Check Vonage Dashboard → Reports → SMS logs for error codes:
    • 0: Success
    • 1: Throttled (rate limit)
    • 2: Missing parameters
    • 3: Invalid parameters
    • 4: Invalid credentials
    • 5: Internal error
    • 6: Invalid message
    • 7: Number barred
    • 8: Partner account barred
    • 9: Partner quota exceeded
  • Account balance: Check Vonage Dashboard → Billing. SMS costs $0.0075-$0.0150 per message (US)
  • Geographic restrictions: Some countries require sender registration (10DLC in US, A2P in India)

Database Connection Errors:

  • Connection string: Verify DATABASE_URL format
    bash
    # PostgreSQL format
    postgresql://user:password@host:5432/database?schema=public
    
    # Test connection
    yarn rw prisma db push
  • Database running: Ensure PostgreSQL server is active
    bash
    # macOS
    brew services list | grep postgresql
    
    # Linux
    systemctl status postgresql
  • Firewall/network: Check if database port (5432 for PostgreSQL) is accessible
    bash
    telnet your-db-host.com 5432
  • Schema out of sync: Run migrations
    bash
    yarn rw prisma migrate dev
    # Or reset for clean slate (DELETES ALL DATA)
    yarn rw prisma migrate reset
  • Connection pooling: For serverless deployments, use connection pooling (PgBouncer, Supabase, or Neon) to prevent exhausting connections

Reminder Function Not Triggering:

  • Cron configuration: Verify vercel.json or netlify.toml syntax
    bash
    # Test cron expression at crontab.guru
    */15 * * * *  # Every 15 minutes
  • CRON_SECRET mismatch: Ensure secret matches in environment and cron request
    bash
    # Check environment variable
    echo $CRON_SECRET
    
    # Test manually
    curl -v https://your-app.com/.redwood/functions/sendReminders -H "x-cron-secret: YOUR_SECRET"
  • Function logs: Check platform logs:
    • Vercel: Dashboard → Functions → sendReminders → Logs
    • Netlify: Dashboard → Functions → sendReminders → Function log
    • System cron: tail -f /var/log/cron.log
  • Reminder window logic: Verify APPOINTMENT_REMINDER_MINUTES_BEFORE calculation
    javascript
    // 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]

  1. Set up WhatsApp Business Account with Vonage (requires approval process)
  2. Replace SMS class in sendSms function:
    javascript
    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)
  3. Update phone number requirements: WhatsApp requires users to opt-in first. Store opt-in status in database.
  4. Message templates: WhatsApp enforces message templates for business-initiated messages. Register templates in Vonage Dashboard.

Reference: Vonage WhatsApp API Documentation

How do I prevent double-booking appointments?

The createAppointment service uses Prisma transactions to prevent race conditions:[9]

javascript
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:

prisma
model Appointment {
  // ... other fields
  slotDateTime DateTime @db.Timestamptz(6)

  @@unique([slotDateTime])  // Prevents duplicates at database level
}

Then run migration: yarn rw prisma migrate dev

The 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:

  • US: +14155551234 (country code 1, area code 415, number 5551234)
  • UK: +442071234567 (country code 44, area code 20, number 71234567)
  • Spain: +34912345678 (country code 34, area code 91, number 2345678)

E.164 specification:

  • Starts with + (optional in code, required by Vonage)
  • Country code: 1-3 digits (mandatory)
  • Subscriber number: remaining digits
  • Total maximum: 15 digits

Recommended: Use libphonenumber-js for robust validation:

bash
yarn workspace api add libphonenumber-js
javascript
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 +14155551234

Client-side: Add country selector dropdown with react-phone-number-input for better UX.

How do I add user authentication to the booking system?

RedwoodJS supports multiple authentication providers through RedwoodJS Auth:[1]

Setup steps:

bash
# Choose a provider: dbAuth (built-in), Auth0, Supabase, Clerk, Firebase, etc.
yarn rw setup auth dbAuth

# Generates: auth functions, login/signup pages, database migrations

Update SDL to require auth:

graphql
type Mutation {
  createAppointment(input: CreateAppointmentInput!): Appointment! @requireAuth  # Changed from @skipAuth
}

Use auth in services:

javascript
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 useAuth hook:

javascript
import { useAuth } from '@redwoodjs/auth'

const { isAuthenticated, currentUser, logIn, logOut } = useAuth()

if (!isAuthenticated) {
  return <Redirect to="/login" />
}

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
}

Client-side: Convert to local time:

javascript
// 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:

javascript
// 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:

  1. Always store timestamps in UTC (Prisma's DateTime with @db.Timestamptz does this automatically)
  2. Pass user's timezone from client (detect with Intl.DateTimeFormat().resolvedOptions().timeZone)
  3. Convert to local time only for display purposes (UI, SMS)
  4. Never perform date math in local time; always use UTC for calculations

What happens if SMS delivery fails?

The sendSms function returns a { success: boolean } object. On failure:

  1. Booking confirmation failure:

    • Appointment is created with confirmed: false
    • User sees warning toast: "Appointment booked but SMS failed"
    • Admin should monitor appointments with confirmed: false and contact users manually
  2. Reminder failure:

    • reminderSent remains false
    • Next cron run will retry automatically (idempotent)
    • After 3 failed attempts, consider moving to dead-letter queue

Implementing a retry queue with RedwoodJS Background Jobs:[1]

bash
yarn rw setup jobs
javascript
// 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:

javascript
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):

  • US/Canada: $0.0075 - $0.0150 per SMS
  • UK: $0.0353 per SMS
  • Most of Europe: $0.08 - $0.15 per SMS
  • India: $0.0069 per SMS
  • Australia: $0.076 per SMS

Multi-part messages:

  • Standard SMS: 160 characters (GSM-7) or 70 characters (Unicode/emoji)
  • Longer messages split into multiple parts, each charged separately
  • Example: 180-character message = 2 SMS charges

Cost estimation:

  • 1000 appointments/month × 2 SMS (confirmation + reminder) = 2000 SMS
  • At $0.01/SMS (average US rate) = $20/month

New accounts: Receive free trial credits ($2-10 depending on region) for testing.

Cost optimization:

  • Keep messages under 160 characters (GSM-7) to avoid multi-part charges
  • Use unicode sparingly (emoji reduces limit to 70 chars)
  • Consider WhatsApp Business API for high volume (cheaper per message, but requires setup)
  • Monitor usage: Vonage Dashboard → Reports → SMS Usage

Check current rates: Vonage SMS Pricing

How do I deploy this application to production?

Recommended platforms for RedwoodJS:

1. Vercel (Easiest):

bash
# Setup
yarn rw setup deploy vercel

# Deploy
yarn rw deploy vercel --prod
  • Auto-deploys from Git on push to main branch
  • Add environment variables: Vercel Dashboard → Settings → Environment Variables
  • Configure cron jobs in vercel.json (see Section 4)
  • Free tier: 100GB bandwidth, 100 serverless function invocations/day

2. Netlify:

bash
# Setup
yarn rw setup deploy netlify

# Deploy
yarn rw deploy netlify --prod
  • Connect Git repository for auto-deploys
  • Add environment variables: Site settings → Environment variables
  • Configure scheduled functions (see Section 4)
  • Free tier: 100GB bandwidth, 125k serverless requests/month

3. Render:

  • Create account at render.com
  • New → Web Service → Connect repository
  • Build command: yarn install && yarn rw build
  • Start command: yarn rw serve
  • Add environment variables in dashboard
  • Create separate Cron Job service for reminders
  • Free tier: 750 hours/month, sleeps after 15min inactivity

Pre-deployment checklist:

  • ✅ Run yarn rw build locally to verify build succeeds
  • ✅ Add all environment variables to hosting platform
  • ✅ Run yarn rw prisma migrate deploy after deployment
  • ✅ Test production API: curl https://your-app.com/.redwood/functions/sendReminders
  • ✅ Verify cron jobs are configured and running
  • ✅ Check database connection (use connection pooling for serverless)
  • ✅ Monitor logs for first 24 hours after deployment
  • ✅ Test SMS sending with real phone number
  • ✅ Set up error monitoring (Sentry, Bugsnag, or LogRocket)

Production monitoring:

  • Vonage Dashboard: Monitor SMS delivery rates, error codes, account balance
  • Platform logs: Check function execution logs for errors
  • Database: Monitor connection pool usage, slow queries
  • Uptime monitoring: Use UptimeRobot or Pingdom to alert on downtime

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