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. It provides structure, conventions, and tooling for rapid development.
- Node.js: The underlying runtime environment for the RedwoodJS API side.
- 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.
- Vonage Messages API: Used to send SMS confirmations and reminders (using
@vonage/server-sdk
v3+). - 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, OS cron): To trigger reminder checks via a Redwood Function.
Project Overview and Goals
We aim to build an application that solves the common problem of scheduling appointments and reducing no-shows through automated reminders.
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).
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+)
v
+--------------------+
| Vonage Messages API|
+--------------------+
^
| (API Call via Function)
+---------+-----------+
| External Scheduler |
| (e.g., Vercel Cron, |
| OS cron) |
+---------------------+
- Web Side: Handles user interaction (displaying the form, submitting data). Communicates with the API side via GraphQL.
- 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.
- Vonage API: External service used by the API side to send SMS messages.
- 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 (v18 or later recommended)
- Yarn (v1 or later) - This guide uses
yarn
commands, butnpm
equivalents generally work. - A Vonage API account (Sign up for free credit)
- 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:
yarn create redwood-app ./vonage-scheduler cd vonage-scheduler
Follow the prompts. Choose TypeScript if you prefer, though this guide uses JavaScript.
-
Install Dependencies: We need the Vonage Server SDK v3+,
uuid
, anddate-fns
.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. -
Database Setup (Prisma): RedwoodJS uses Prisma for database interaction.
- Open
api/db/schema.prisma
. - Configure the
datasource db
block for your chosen database. For PostgreSQL:// api/db/schema.prisma datasource db { provider = ""postgresql"" // Corrected quotes url = env(""DATABASE_URL"") } generator client { provider = ""prisma-client-js"" // Corrected quotes binaryTargets = [""native""] // Corrected quotes and array syntax }
- If using SQLite (good for local development):
// api/db/schema.prisma datasource db { provider = ""sqlite"" // Corrected quotes url = env(""DATABASE_URL"") } // ... rest of the file
- Open
-
Define Database Schema: Add the
Appointment
model toapi/db/schema.prisma
:// api/db/schema.prisma model Appointment { id Int @id @default(autoincrement()) slotDateTime DateTime // The date and time of the appointment (stored in UTC) phoneNumber String // User's phone number (E.164 format strongly recommended) 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]) // Index for efficient querying by time }
slotDateTime
: Stores the exact date and time, ideally in UTC.phoneNumber
: Stores the recipient number for SMS. E.164 format is crucial.bookingCode
: A unique identifier generated during booking.confirmed
,reminderSent
: Flags to track SMS status success.@@index
: Improves performance when searching for appointments by time.
-
Create and Apply Migration: This command creates SQL migration files based on your schema changes and applies them to your database.
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 likeAPPOINTMENT_REMINDER_MINUTES_BEFORE
.# .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 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 # CRON_SECRET=""YOUR_STRONG_RANDOM_SECRET""
DATABASE_URL
: The connection string for your database. Ensure it matches the provider inschema.prisma
.VONAGE_API_KEY
/VONAGE_API_SECRET
: Found on your Vonage Dashboard under ""API settings"". Treat these as secrets! Do not commit them to Git.VONAGE_FROM_NUMBER
: A Vonage virtual number capable of sending SMS, purchased from the Vonage Dashboard under ""Numbers"". Ensure it's in E.164 format (e.g.,14155550100
).APPOINTMENT_REMINDER_MINUTES_BEFORE
: How long before the appointment the reminder should be sent.CRON_SECRET
(Optional but Recommended): A secret key to verify requests to thesendReminders
function if triggered via HTTP.
-
Initialize Vonage Client (API Side): Create a utility file to initialize the Vonage client instance using the v3 SDK.
// 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.') // return null // Avoid returning null in production } try { // Using v3 SDK initialization vonageInstance = new Vonage({ apiKey: apiKey, apiSecret: apiSecret, // applicationId: 'YOUR_VONAGE_APPLICATION_ID', // Optional: Needed for other APIs like Voice/Video // privateKey: './private.key', // Optional: Needed for other APIs }) logger.info('Vonage client initialized successfully (v3 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) // Consistent return pattern for failures within this function return { success: false, error: errorMsg } } // Basic validation (E.164 format is strongly recommended for 'to' and 'from') 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-like. 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 Vonage v3 Messages API syntax const resp = await vonage.messages.send( new SMS({ to: to, from: from, text: text, // clientRef: 'your-internal-ref-123', // Optional client reference }) ) logger.info({ messageUuid: resp.messageUuid, to }, 'SMS submitted to Vonage successfully.') // The v3 SDK confirms submission; final delivery status comes via webhooks (if configured) or reporting. // For this guide, we consider successful submission as success. return { success: true, messageId: resp.messageUuid } // messageId is now messageUuid in v3 } catch (error) { // v3 SDK throws errors for API issues (auth, bad request, network, etc.) const errorDetails = error.response?.data || error.message || error logger.error({ error: errorDetails, to }, 'Error sending SMS via Vonage Messages API') return { success: false, error: 'Vonage API Error', details: errorDetails } } }
- Uses
@vonage/server-sdk
v3+ syntax (new SMS(...)
,vonage.messages.send(...)
). - Throws errors during initialization if keys are missing.
sendSms
helper adapted for v3:- Uses
SMS
class. - Handles the promise returned by
vonage.messages.send
. - Returns
{ success: boolean, ... }
for consistency within the app's logic, capturing errors from the SDK's thrown exceptions. - Returns
messageUuid
instead of the oldmessage-id
. - Includes basic E.164 format check for
to
/from
.
- Uses
- Uses
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.graphql
file if preferred) to include a specific input type for creation and define thecreateAppointment
mutation.# 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.
-
Implement the Service Logic: Update the
createAppointment
function 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 if (appointmentTime <= now) { throw new UserInputError('Appointment time must be in the future.') } // --- 2. Check Availability (Example: No double booking the exact slot) --- // Consider potential race conditions if high traffic is expected. // Database transactions might be needed for robust checks. const existingAppointment = await db.appointment.findFirst({ where: { slotDateTime: appointmentTime }_ }) if (existingAppointment) { logger.warn({ slotDateTime }_ 'Attempted to book an already taken slot') throw new UserInputError('This appointment slot is already booked.') } // --- 3. Generate Booking Code --- // Using a substring of UUID increases collision chance vs full UUID or DB sequence. // Acceptable for low/moderate volume_ consider alternatives for high volume. const bookingCode = uuidv4().substring(0_ 8).toUpperCase() logger.info(`Generated booking code: ${bookingCode}`) // --- 4. Save to Database --- let newAppointment try { newAppointment = await db.appointment.create({ data: { slotDateTime: appointmentTime_ // Stored as UTC if DB field type supports it phoneNumber: formattedPhoneNumber_ // Store validated/formatted number bookingCode: bookingCode_ confirmed: false_ // Mark as not confirmed until SMS is sent successfully reminderSent: false_ }_ }) logger.info({ appointmentId: newAppointment.id_ bookingCode }_ 'Appointment saved to database') } catch (dbError) { logger.error({ dbError }_ 'Database error creating appointment') // Handle potential unique constraint violation on bookingCode (retry generation?) if (dbError.code === 'P2002' && dbError.meta?.target?.includes('bookingCode')) { logger.warn('Booking code collision detected. Consider a more robust generation strategy.') // Potentially retry with a new code or fail gracefully } throw new Error('Failed to save appointment due to a database issue. Please try again.') } // --- 5. Send Confirmation SMS --- const confirmationText = `Your appointment is booked for ${appointmentTime.toLocaleString()}. Your booking code: ${bookingCode}.` // Note: sendSms returns { success: boolean_ ... } instead of throwing for SMS-specific errors const smsResult = await sendSms(newAppointment.phoneNumber_ confirmationText) // --- 6. Update Confirmation Status Based on SMS Result --- if (smsResult.success) { logger.info({ appointmentId: newAppointment.id_ messageId: smsResult.messageId }_ 'Confirmation SMS submitted successfully.') try { // Update the DB record to reflect successful SMS submission const updatedAppt = await db.appointment.update({ where: { id: newAppointment.id }_ data: { confirmed: true }_ }) // Update the object we're about to return to match the DB state newAppointment.confirmed = updatedAppt.confirmed; logger.info({ appointmentId: newAppointment.id }_ 'Appointment confirmed status updated in DB.') } catch (updateError) { // Log error_ but don't fail the whole operation if SMS was sent logger.error({ updateError_ appointmentId: newAppointment.id }_ 'Failed to update confirmation status in DB after SMS success.') // The returned appointment object will have confirmed: false_ but SMS was sent. } } else { logger.error({ appointmentId: newAppointment.id_ error: smsResult.error_ details: smsResult.details }_ 'Failed to send confirmation SMS.') // Decide how to handle failure: // - Return the appointment anyway (as done here)? User has no confirmation. // - Delete the appointment? (Could lead to race conditions) // - Add to a retry queue? (More complex) // Current approach: Log the error_ return the appointment with confirmed: false. } // --- 7. Return the Created Appointment --- // The returned object reflects the DB state *after* the attempted SMS send and status update. return newAppointment } // Keep other generated service functions (appointment_ update_ delete) // or remove them if not needed initially. Implement authorization (@requireAuth). // export const appointment = ({ id }) => { ... } // export const updateAppointment = ({ id, input }) => { ... } // export const deleteAppointment = ({ id }) => { ... }
- Validation: Added recommendation and commented-out example for
libphonenumber-js
. Added validation forslotDateTime
format. - Availability Check: Added comment about potential race conditions.
- Booking Code: Added comment about substring collision risk and potential handling of unique constraint errors.
- Database Save: Stores the potentially formatted phone number. Includes better error logging for unique constraints.
- SMS Confirmation: Calls the updated
sendSms
helper. - Status Update: Updates
confirmed
in the DB only ifsendSms
returnssuccess: true
. Updates the in-memorynewAppointment
object to match the DB state before returning it. Includes logging for DB update failures post-SMS success. - Error Handling: Uses Redwood's
logger
, handles potential database and SMS errors based on thesendSms
return value.
- Validation: Added recommendation and commented-out example for
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' // Import gql // 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 # We get this back after creation confirmed # Check if confirmation SMS was sent } } ` 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.` ) } 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 if needed.` ) } // Reset form by changing the key of the Form component setFormKey(Date.now()) }, onError: (error) => { // Errors from the service (like UserInputError) or network errors are caught here toast.error(`Booking failed: ${error.message}`) }, } ) const onSubmit = (formData) => { // Ensure slotDateTime is sent in ISO string format (UTC) // datetime-local input gives local time, new Date().toISOString() converts to UTC const input = { slotDateTime: new Date(formData.slotDateTime).toISOString(), phoneNumber: formData.phoneNumber, // Send as entered, backend validates/formats } createAppointment({ variables: { input } }) } // Basic styling using Tailwind (ensure Tailwind is setup) 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 allowed datetime for the input (e.g., 1 hour from now) const getMinDateTime = () => { const now = new Date(); now.setHours(now.getHours() + 1); // At least 1 hour in the future // Format for datetime-local input (YYYY-MM-DDTHH:mm) return now.toISOString().slice(0, 16); } 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""> <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: true_ valueAsDate: true }} min={getMinDateTime()} // Prevent past/too-soon dates /> <FieldError name=""slotDateTime"" className={errorStyle} /> </div> <div className=""mb-4""> <Label name=""phoneNumber"" className={labelStyle} errorClassName={`${labelStyle} text-red-700`}> Phone number (e.g., +15551234567): </Label> <TextField name=""phoneNumber"" className={inputStyle} errorClassName={`${inputStyle} border-red-500`} validation={{ required: 'Phone number is required'_ pattern: { // Basic pattern - recommend server-side validation with libphonenumber-js value: /^\+?[1-9]\d{1_14}$/_ message: 'Use E.164 format (e.g. +15551234567)' } }} placeholder=""+15551234567"" /> <FieldError name=""phoneNumber"" className={errorStyle} /> </div> {/* Display general mutation error */} {error && ( <div className=""mb-4 p-3 bg-red-100 text-red-700 rounded""> 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""> {loading ? 'Booking...' : 'Book Appointment'} </Submit> </Form> </div> </> ) } export default AppointmentBookingPage
gql
Import: Addedimport gql from 'graphql-tag'
as it's used.useMutation
Hook: UpdatedonCompleted
to check theconfirmed
status returned from the mutation and provide more specific user feedback usingtoast.warn
if confirmation failed. Added form reset logic using a changingkey
prop on theForm
.- Styling: Ensured consistent double quotes for JSX attributes and string literals.
- Form Input: Added
min
attribute toDatetimeLocalField
to prevent selecting past dates dynamically. Improved validation messages. - Data Handling: Explicitly notes that
datetime-local
value needs conversion to ISO string (UTC) for the backend.
-
Run the Development Server:
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 theconfirmed
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 (like OS cron, GitHub Actions Schedule, Vercel Cron Jobs, Render Cron Jobs, etc.). Running cron within a potentially serverless API function is unreliable.
- Create a Redwood Function:
(The rest of Section 4 was not provided in the original input)
yarn rw g function sendReminders # Creates api/src/functions/sendReminders.js