Build Production-Ready SMS Scheduling & Reminders with RedwoodJS, Infobip, and Node.js
A guide to building a full-stack application for scheduling and sending SMS reminders using RedwoodJS, Infobip, Node.js, Prisma, and PostgreSQL.
Code examples17 min read
Frequently asked questions
Use RedwoodJS's full-stack framework along with Infobip's SMS API and Node.js for scheduling. The application allows users to schedule appointments through a web interface, and the system automatically sends SMS reminders at the designated times, handling time zones correctly.
RedwoodJS is the core framework for building both the frontend (using React) and the backend (GraphQL API) of the SMS scheduling application. It provides structure, conventions, and developer-friendly features like generators and cells.
Infobip is a CPaaS provider offering a robust SMS API and a convenient Node.js SDK, making it straightforward to integrate SMS sending functionality into the application. Their platform is designed for reliable message delivery.
Use date-fns-tz for accurate timezone conversions, especially when scheduling tasks. It's crucial for ensuring that SMS reminders are sent at the correct local time for recipients in different time zones and for handling daylight saving time.
Install the Infobip Node.js SDK (@infobip-api/sdk) and configure your API key and base URL in the .env file. Then create a service to interact with the Infobip API using the SDK to send SMS messages.
PostgreSQL is used as the database to store appointment details, recipient information, and message status. Prisma, an ORM, simplifies database interactions within the RedwoodJS application.
node-cron is a Node.js library that provides a simple way to schedule tasks using cron-like expressions. It's used to trigger SMS sending at the correct times, based on the scheduled time and the recipient's time zone.
Use date-fns-tz to calculate the correct cron expression based on the UTC scheduled time and the recipient's time zone. This will ensure that the SMS message is sent at the correct time for every user, regardless of their location.
Use the yarn create redwood-app command. Initialize a TypeScript project, configure your database connection in the .env file, and install necessary dependencies like the Infobip SDK, node-cron, date-fns-tz, and type definitions.
Prisma acts as an Object-Relational Mapper (ORM), simplifying database interactions. It allows you to define your data models and easily perform CRUD (Create, Read, Update, Delete) operations on your PostgreSQL database from your RedwoodJS backend.
Define your data model in the api/db/schema.prisma file. This file uses the Prisma schema language. Then run yarn rw prisma migrate dev to create and apply the migration to your development database.
Use the Infobip Node.js SDK in your API side. You will need your API key and base URL, available from your Infobip account dashboard. Provide the recipient's phone number and message text to the sendSms function.
Yes, update the appointment's details using an appropriate API call, including the new scheduled time and timezone if needed. The application will cancel any existing job and reschedule the SMS reminder based on the updated information.
You will need Node.js (version 20 or higher recommended), Yarn, an Infobip account, access to a PostgreSQL database, and basic familiarity with JavaScript/TypeScript, React, GraphQL, and terminal commands.
One API. Every channel.
Contact Sales
This guide provides a complete walkthrough for building a robust application that schedules and sends SMS reminders using RedwoodJS for the full-stack framework, Infobip for SMS delivery, and Node.js features for scheduling. We'll cover everything from initial setup to deployment and monitoring.
By the end of this tutorial, you will have a functional web application where users can schedule appointments (or any event), and the system will automatically send an SMS reminder via Infobip at the specified time, respecting time zones. This solves the common business problem of needing automated, reliable communication for appointments, notifications, or alerts, helping reduce no-shows and improve user engagement.
Project Overview and Goals
We aim to build a full-stack application with the following capabilities:
node-cronrunning on the Node.js backend to trigger SMS sending at the correct time, accounting for different time zones.Technologies:
node-cron: A simple cron-like job scheduler for Node.js. Chosen for its simplicity in scheduling tasks within the Node.js process.System Architecture:
Prerequisites:
1. Setting up the Project
Let's initialize our RedwoodJS project and install necessary dependencies.
Verify Node/Yarn Versions: Open your terminal and check your versions:
If needed, update Node.js (using
nvmis recommended:nvm install 20 && nvm use 20) and Yarn (npm install -g yarn).Create RedwoodJS App: Use the
create-redwood-appcommand. We'll use TypeScript (default) and initialize a git repo.Follow the prompts (commit message, yarn install).
Navigate to Project Directory:
cd redwood-infobip-schedulerConfigure Database Connection: Locate the
.envfile in the project root. Update theDATABASE_URLvariable to point to your PostgreSQL database. Ensure the quotes are correct. Example for a local setup:# .env DATABASE_URL=""postgresql://postgres:password@localhost:5432/schedulerdb?schema=public""Replace
postgres,password,localhost,5432, andschedulerdbwith your actual database credentials and name.Install Dependencies: We need the Infobip SDK,
node-cronfor scheduling,date-fns-tzfor robust timezone handling, and type definitions.@infobip-api/sdk: The official SDK for interacting with Infobip APIs.node-cron: To schedule the SMS sending tasks.date-fns-tz: For accurate timezone conversions, essential for reliable scheduling.@types/node-cron: TypeScript definitions fornode-cron.Configure Infobip Credentials: Add your Infobip API Key and Base URL to the
.envfile. You can find these in your Infobip account dashboard under API Keys. Also, define a sender ID (optional, but recommended).xxxxx.api.infobip.com).Add the following lines to your
.envfile:# .env # ... existing variables INFOBIP_API_KEY=""YOUR_INFOBIP_API_KEY"" INFOBIP_BASE_URL=""YOUR_INFOBIP_BASE_URL"" # e.g., xxxxx.api.infobip.com INFOBIP_SENDER_ID=""InfoSMS"" # Optional: Customize your sender nameImportant: Replace
YOUR_INFOBIP_API_KEYandYOUR_INFOBIP_BASE_URLwith the actual credentials obtained from your Infobip account. These are sensitive secrets. Security Note: Never commit your.envfile to version control. Ensure.envis listed in your.gitignorefile (it should be by default in RedwoodJS).Initial Commit (if not done during setup):
git add . git commit -m ""Initial project setup with dependencies""2. Database Schema and Data Layer
We need a database table to store information about the appointments or scheduled messages.
Define Prisma Schema: Open
api/db/schema.prismaand define theAppointmentmodel.recipientPhone: The destination phone number. Using E.164 format is crucial for international compatibility.scheduledAt: The date and time in UTC when the reminder should be sent.timeZone: The recipient's local time zone (IANA format). This is critical for scheduling the job correctly.status: Tracks the state of the reminder.infobipMessageId: Useful for tracking delivery status via Infobip later.failureReason: Optional field to store error messages if sending fails.@@index([status, scheduledAt])for query performance.Create and Apply Migration: Run the following command to generate the SQL migration file and apply it to your development database:
When prompted, provide a name for the migration (e.g.,
create_appointment_model). This command also generates/updates the Prisma Client based on your schema.3. Core Functionality - API Layer
Now, let's build the backend logic: API endpoints for managing appointments, integrating with Infobip, and setting up the scheduler.
Generate GraphQL SDL and Service: Redwood's generators make this easy.
This creates:
api/src/graphql/appointments.sdl.ts: Defines the GraphQL schema (types, queries, mutations).api/src/services/appointments/appointments.ts: Implements the resolvers (business logic) for the SDL.api/src/services/appointments/appointments.scenarios.ts: For seeding test data.api/src/services/appointments/appointments.test.ts: Unit test file.Implement CRUD Operations: Open
api/src/services/appointments/appointments.tsand implement the basic CRUD functions.// api/src/services/appointments/appointments.ts import type { QueryResolvers, MutationResolvers } from 'types/graphql' import { db } from 'src/lib/db' import { scheduleAppointmentJob, cancelScheduledJob } from 'src/lib/scheduler' import { requireAuth } from 'src/lib/auth' // Assuming auth is set up export const appointments: QueryResolvers['appointments'] = () => { // requireAuth() // Secure this endpoint if needed return db.appointment.findMany({ orderBy: { scheduledAt: 'asc' } }) } export const appointment: QueryResolvers['appointment'] = ({ id }) => { // requireAuth() // Secure this endpoint if needed return db.appointment.findUnique({ where: { id }, }) } export const createAppointment: MutationResolvers['createAppointment'] = async ({ input }) => { // requireAuth() // Secure this mutation // **Backend Validation:** Always validate input on the server-side. if (!input.recipientPhone || !input.message || !input.scheduledAt || !input.timeZone) { throw new Error('Missing required appointment fields.') } // Add more specific validation (e.g., E.164 format for phone, length checks) const phoneRegex = /^\+[1-9]\d{1,14}$/; if (!phoneRegex.test(input.recipientPhone)) { throw new Error('Invalid phone number format. Use E.164 (e.g., +14155552671).'); } const scheduledAtUTC = new Date(input.scheduledAt); if (isNaN(scheduledAtUTC.getTime())) { throw new Error('Invalid scheduled date/time format.'); } const newAppointment = await db.appointment.create({ data: { ...input, scheduledAt: scheduledAtUTC, // Ensure it's a Date object (UTC) status: 'PENDING', failureReason: null, // Initialize failure reason }, }) // Schedule the job after successfully creating the appointment try { await scheduleAppointmentJob(newAppointment); } catch (scheduleError) { // If scheduling fails immediately, update status and re-throw or handle await db.appointment.update({ where: { id: newAppointment.id }, data: { status: 'FAILED', failureReason: `Scheduling failed: ${scheduleError.message}` } }); throw scheduleError; // Inform the client } return newAppointment } export const updateAppointment: MutationResolvers['updateAppointment'] = async ({ id, input }) => { // requireAuth() // Secure this mutation const existingAppointment = await db.appointment.findUnique({ where: { id } }); if (!existingAppointment) { throw new Error(`Appointment with ID ${id} not found.`); } // **Backend Validation** for updated fields as well if (input.recipientPhone && !/^\+[1-9]\d{1,14}$/.test(input.recipientPhone)) { throw new Error('Invalid phone number format. Use E.164 (e.g., +14155552671).'); } if (input.scheduledAt && isNaN(new Date(input.scheduledAt).getTime())) { throw new Error('Invalid scheduled date/time format.'); } // Cancel the old job before updating await cancelScheduledJob(existingAppointment.id); const updatedAppointment = await db.appointment.update({ // Reset status to PENDING if relevant fields change? Or keep existing? Decide based on logic. // Here we assume input might contain status, but usually shouldn't directly. data: { ...input, scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : undefined, // Ensure Date object if present // Reset failure reason if relevant fields are updated? failureReason: null, status: existingAppointment.status === 'FAILED' ? 'PENDING' : existingAppointment.status, // Example: Allow retrying FAILED ones }, where: { id }, }) // Reschedule with updated details only if it's meant to be active if (updatedAppointment.status === 'PENDING') { try { await scheduleAppointmentJob(updatedAppointment); } catch (scheduleError) { // Handle rescheduling failure await db.appointment.update({ where: { id: updatedAppointment.id }, data: { status: 'FAILED', failureReason: `Rescheduling failed: ${scheduleError.message}` } }); throw scheduleError; } } return updatedAppointment } export const deleteAppointment: MutationResolvers['deleteAppointment'] = async ({ id }) => { // requireAuth() // Secure this mutation const existingAppointment = await db.appointment.findUnique({ where: { id } }); if (!existingAppointment) { // Optional: Return null or a specific message instead of throwing an error // if deleting a non-existent item shouldn't be an error. throw new Error(`Appointment with ID ${id} not found.`); } // Cancel the scheduled job before deleting await cancelScheduledJob(existingAppointment.id); return db.appointment.delete({ where: { id }, }) }createAppointmentandupdateAppointment.try...catcharoundscheduleAppointmentJobcalls to handle potential scheduling errors during create/update.requireAuth()comments as placeholders (uncomment/implement if auth is set up).failureReason.Implement Infobip Integration: Create/update the utility file for Infobip interactions.
// api/src/lib/infobip.ts import { Infobip, AuthType } from '@infobip-api/sdk' import { logger } from 'src/lib/logger' const apiKey = process.env.INFOBIP_API_KEY const baseUrl = process.env.INFOBIP_BASE_URL if (!apiKey || !baseUrl) { // Log error but don't throw here, allows server to start maybe? // Throwing might be better to prevent operation without config. logger.error('Infobip API Key or Base URL not configured in .env file. SMS sending will fail.') // Or: throw new Error('Infobip API Key or Base URL not configured in .env file.') } // Initialize client conditionally let infobipClient: Infobip | null = null; if (apiKey && baseUrl) { infobipClient = new Infobip({ baseUrl: baseUrl, apiKey: apiKey, authType: AuthType.ApiKey, }); } else { logger.warn('Infobip client not initialized due to missing credentials.'); } export const sendSms = async (to: string, text: string): Promise<{ success: boolean; messageId?: string; error?: string }> => { if (!infobipClient) { const errorMsg = 'Infobip client is not initialized. Check API Key/Base URL configuration.'; logger.error(errorMsg); return { success: false, error: errorMsg }; } const sender = process.env.INFOBIP_SENDER_ID || 'RedwoodApp' // Use default if not set // Basic validation if (!to || !text) { const errorMsg = 'Missing recipient phone or message text for SMS.'; logger.error(errorMsg) return { success: false, error: errorMsg } } // E.164 validation (should ideally happen before calling this) if (!/^\+[1-9]\d{1,14}$/.test(to)) { const errorMsg = `Invalid recipient phone format: ${to}. Use E.164.`; logger.error(errorMsg); return { success: false, error: errorMsg }; } logger.info(`Attempting to send SMS via Infobip to ${to}`) try { const response = await infobipClient.channels.sms.send({ messages: [ { destinations: [{ to }], from: sender, text: text, }, ], }) const messageResponse = response.data.messages?.[0] // Check groupName for success indication (PENDING usually means accepted by Infobip) if (messageResponse?.status?.groupName === 'PENDING' || messageResponse?.status?.groupName === 'DELIVERED') { // DELIVERED might occur for very fast paths logger.info(`Infobip accepted SMS: ${messageResponse.messageId}, Status: ${messageResponse.status.name} (${messageResponse.status.groupName})`) return { success: true, messageId: messageResponse.messageId } } else { const errorText = messageResponse?.status?.description || `Status: ${messageResponse?.status?.name ?? 'Unknown'}, Group: ${messageResponse?.status?.groupName ?? 'Unknown'}`; logger.error({ infobipResponse: response.data }, `Infobip SMS sending failed or status not PENDING/DELIVERED. ${errorText}`) return { success: false, error: `Infobip error: ${errorText}` } } } catch (error) { logger.error({ error: error?.response?.data || error.message }, 'Error sending SMS via Infobip SDK') let errorMessage = 'Unknown Infobip SDK error'; if (error.response?.data?.requestError?.serviceException?.text) { errorMessage = `Infobip API Error: ${error.response.data.requestError.serviceException.text}`; } else if (error.message) { errorMessage = error.message; } else if (typeof error.response?.data === 'string') { errorMessage = `Infobip API Error: ${error.response.data}`; } return { success: false, error: errorMessage } } }Implement Scheduling Logic: Create/update the file for the
node-cronscheduler, incorporating robust timezone handling and clarifying persistence limitations.// api/src/lib/scheduler.ts import cron from 'node-cron' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' import { sendSms } from './infobip' import type { Appointment } from '@prisma/client' import { formatInTimeZone, zonedTimeToUtc } from 'date-fns-tz' // For robust TZ handling // **Limitation:** This in-memory map stores active cron jobs. // It is NOT persistent across server restarts or suitable for serverless/multi-instance deployments. // `initializeScheduler` reloads PENDING jobs on startup, but jobs scheduled between restarts might be missed // without a persistent job queue (e.g., BullMQ, Agenda). const scheduledJobs = new Map<number, cron.ScheduledTask>(); // Function to get Cron pattern using the target timezone for accuracy const getCronPattern = (dateUtc: Date, timeZone: string): string => { try { // Format the UTC date into the components (minute, hour, etc.) *as they are in the target timezone* const minute = formatInTimeZone(dateUtc, timeZone, 'm'); // 0-59 const hour = formatInTimeZone(dateUtc, timeZone, 'H'); // 0-23 const dayOfMonth = formatInTimeZone(dateUtc, timeZone, 'd'); // 1-31 const month = formatInTimeZone(dateUtc, timeZone, 'M'); // 1-12 // dayOfWeek is not strictly needed if dayOfMonth and month are set, use '*' const dayOfWeek = '*'; // Cron Pattern: minute hour dayOfMonth month dayOfWeek (seconds are optional, default to 0) const pattern = `${minute} ${hour} ${dayOfMonth} ${month} ${dayOfWeek}`; logger.info(`Generated cron pattern: ${pattern} for date ${dateUtc.toISOString()} in target TZ ${timeZone}`); return pattern; } catch (error) { logger.error({ error, dateUtc, timeZone }, `Failed to generate cron pattern using date-fns-tz. Invalid timezone?`); throw new Error(`Invalid timezone or date for pattern generation: ${timeZone}`); } } // Function to execute when a job runs const executeJob = async (appointment: Appointment) => { logger.info(`Executing job for Appointment ID: ${appointment.id} to ${appointment.recipientPhone}`); // Refetch to get the absolute latest status before sending const currentAppointment = await db.appointment.findUnique({ where: { id: appointment.id } }); if (!currentAppointment) { logger.warn(`Job execution skipped: Appointment ID ${appointment.id} not found.`); cancelScheduledJob(appointment.id); // Clean up map return; } if (currentAppointment.status !== 'PENDING') { logger.warn(`Job execution skipped for Appointment ID: ${appointment.id}. Status is ${currentAppointment.status}, not PENDING.`); cancelScheduledJob(appointment.id); // Clean up map return; } // Double-check time hasn't passed due to potential delays/restarts? Optional. // if (new Date(currentAppointment.scheduledAt) <= new Date()) { ... } const result = await sendSms(currentAppointment.recipientPhone, currentAppointment.message); const newStatus = result.success ? 'SENT' : 'FAILED'; const failureReason = result.success ? null : (result.error || 'Unknown Infobip sending error'); await db.appointment.update({ where: { id: appointment.id }, data: { status: newStatus, infobipMessageId: result.messageId, failureReason: failureReason, }, }); logger.info(`Job finished for Appointment ID: ${appointment.id}. Status updated to ${newStatus}. Infobip Msg ID: ${result.messageId ?? 'N/A'}`); // Remove the completed/failed job from the map cancelScheduledJob(appointment.id); // Use cancel function to ensure cleanup } // Function to schedule a single appointment job export const scheduleAppointmentJob = async (appointment: Appointment) => { if (appointment.status !== 'PENDING') { logger.info(`Skipping scheduling for Appointment ID: ${appointment.id}. Status: ${appointment.status}`); return; } const scheduledAtUTC = new Date(appointment.scheduledAt); if (scheduledAtUTC <= new Date()) { logger.warn(`Skipping scheduling for Appointment ID: ${appointment.id}. Scheduled time is in the past.`); await db.appointment.update({ where: { id: appointment.id }, data: { status: 'FAILED', failureReason: 'Scheduled time is in the past.' } }); return; } // Cancel any existing job for this ID first await cancelScheduledJob(appointment.id); try { // Generate pattern based on target timezone using date-fns-tz const cronPattern = getCronPattern(scheduledAtUTC, appointment.timeZone); // Schedule the job using node-cron. // We use the pattern derived from the target timezone. // Do NOT use node-cron's `timezone` option simultaneously unless you fully understand the interaction. // Using the pattern generated by date-fns-tz should be sufficient as it accounts for DST. const task = cron.schedule(cronPattern, async () => { try { // Pass the original appointment data to avoid potential race conditions, // executeJob will refetch the latest state internally. await executeJob(appointment); } catch (error) { logger.error({ error, appointmentId: appointment.id }, 'Unhandled error during job execution wrapper'); try { await db.appointment.update({ where: { id: appointment.id }, data: { status: 'FAILED', failureReason: `Job execution error: ${error.message}` }, }); } catch (updateError) { logger.error({ updateError, appointmentId: appointment.id }, 'Failed to update status after job execution error'); } // Ensure job is removed from map even on unhandled failure cancelScheduledJob(appointment.id); } }, { scheduled: true, // timezone: undefined // Explicitly avoid node-cron timezone if using date-fns-tz pattern }); // Store the scheduled task scheduledJobs.set(appointment.id, task); logger.info(`Scheduled job for Appointment ID: ${appointment.id} with pattern ""${cronPattern}"" for target TZ ${appointment.timeZone}`); } catch (error) { logger.error({ error, appointmentId: appointment.id }, 'Failed to schedule cron job'); await db.appointment.update({ where: { id: appointment.id }, data: { status: 'FAILED', failureReason: `Failed to schedule: ${error.message}` } }); // Re-throw or handle as needed, maybe notify admin? throw error; // Propagate error to caller (e.g., mutation) } } // Function to cancel a specific scheduled job export const cancelScheduledJob = async (appointmentId: number) => { if (scheduledJobs.has(appointmentId)) { logger.info(`Cancelling job for Appointment ID: ${appointmentId}`); scheduledJobs.get(appointmentId)?.stop(); scheduledJobs.delete(appointmentId); } // No need to update DB status here, cancellation is usually followed by update/delete } // Function to initialize scheduler on server start - CRITICAL for reliability export const initializeScheduler = async () => { logger.info('Initializing scheduler: Loading pending jobs from database...'); // Clear any existing jobs from map (in case of unclean shutdown/restart logic) scheduledJobs.forEach(job => job.stop()); scheduledJobs.clear(); // Fetch all PENDING appointments from the DB that are scheduled for the future const pendingAppointments = await db.appointment.findMany({ where: { status: 'PENDING', scheduledAt: { gt: new Date(), // Only schedule future appointments }, }, }); logger.info(`Found ${pendingAppointments.length} pending appointments to re-schedule.`); let successCount = 0; let failureCount = 0; // Schedule jobs for each pending appointment for (const appointment of pendingAppointments) { try { await scheduleAppointmentJob(appointment); successCount++; } catch (error) { logger.error({ error, appointmentId: appointment.id }, `Failed to re-schedule job during initialization`); failureCount++; // Status should have been updated to FAILED inside scheduleAppointmentJob's error handler } } logger.info(`Scheduler initialization complete. Successfully scheduled: ${successCount}, Failed: ${failureCount}.`); } // **Integration Point:** `initializeScheduler` MUST be called once when the API server starts. // This is essential to load jobs after restarts. // **Recommended approach:** Modify Redwood's `api/src/server.ts` file or use a dedicated // server startup hook/script if available in your deployment environment. // **Avoid** calling it within request handlers (like GraphQL context) as it should only run once on startup. // Refer to RedwoodJS documentation for customizing the server setup or lifecycle hooks. // Example (Conceptual - check RedwoodJS docs for the right way): /* // In api/src/server.ts (or similar entry point) import { initializeScheduler } from 'src/lib/scheduler' // ... other imports and server setup ... const startServer = async () => { // ... existing server setup ... // Initialize scheduler AFTER other setup (like DB connection) is likely ready try { await initializeScheduler(); } catch (error) { logger.error({ error }, ""Failed to initialize scheduler on startup""); // Decide if server should proceed or exit based on severity } // ... start listening ... } startServer(); */date-fns-tzimport and used it ingetCronPatternfor robust timezone handling.timezoneoption fromcron.schedule.scheduledJobsmap and its limitations, mentioning persistent queues earlier.initializeSchedulermust be called, strongly advising against the GraphQL context and recommendingapi/src/server.tsor similar startup hooks. Added a conceptual example.initializeSchedulerloop.executeJobnow refetches the appointment for latest status.4. Building the Frontend
Let's create the UI for managing appointments using RedwoodJS Cells and Forms.
Generate Page and Cell:
This creates:
web/src/pages/AppointmentsPage/AppointmentsPage.tsxweb/src/components/AppointmentsCell/AppointmentsCell.{tsx,mock.ts,test.tsx}Implement AppointmentsCell: Edit
web/src/components/AppointmentsCell/AppointmentsCell.tsx.// web/src/components/AppointmentsCell/AppointmentsCell.tsx import type { AppointmentsQuery, AppointmentsQueryVariables } from 'types/graphql' import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web' import AppointmentForm from 'src/components/AppointmentForm/AppointmentForm' export const QUERY = gql` query AppointmentsQuery { appointments { id recipientName recipientPhone message scheduledAt timeZone status failureReason # Added failureReason createdAt infobipMessageId # Added Infobip ID } } ` export const Loading = () => <div>Loading appointments...</div> export const Empty = () => ( <div> <h2>No appointments scheduled yet.</h2> <AppointmentForm /> </div> ) export const Failure = ({ error }: CellFailureProps<AppointmentsQueryVariables>) => ( <div style={{ color: 'red' }}>Error loading appointments: {error?.message}</div> ) export const Success = ({ appointments, refetch }: CellSuccessProps<AppointmentsQuery, AppointmentsQueryVariables>) => { return ( <div> <h2>Scheduled Appointments</h2> {/* Add a refetch button maybe? */} {/* Add table or list display of appointments here */} {/* Add AppointmentForm for creating new ones */} {/* Example: <AppointmentForm onSuccess={refetch} /> */} {/* Example: <AppointmentsList appointments={appointments} onDelete={refetch} onUpdate={refetch} /> */} </div> ) }failureReasonandinfobipMessageIdto theQUERY.Successcomponent for where the list/table and form would go.AppointmentForm(which would need to be created separately, likely withyarn rw g component AppointmentForm) is beyond the scope of this snippet but would be the next step here.