code examples
code examples
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.
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:
- Web Interface: A frontend built with RedwoodJS (React) to create, view, and manage scheduled SMS messages (appointments).
- API Backend: A RedwoodJS API (GraphQL) to handle data persistence (using Prisma and PostgreSQL) and interact with the scheduling and SMS sending logic.
- SMS Sending: Integration with the Infobip SMS API using their official Node.js SDK to dispatch messages.
- Scheduling: A reliable mechanism using
node-cronrunning on the Node.js backend to trigger SMS sending at the correct time, accounting for different time zones. - Persistence: Storing appointment details, recipient information, and status in a PostgreSQL database via Prisma.
Technologies:
- RedwoodJS: A full-stack JavaScript/TypeScript framework for the web. Chosen for its integrated frontend (React) and backend (GraphQL API, Prisma), conventions, and developer experience features (generators, cells, etc.).
- Node.js: The runtime environment for the RedwoodJS backend and scheduling logic.
- Infobip: A communications platform-as-a-service (CPaaS) provider. Chosen for its robust SMS API and Node.js SDK.
- Prisma: A next-generation ORM for Node.js and TypeScript. RedwoodJS's default ORM, simplifying database interactions.
- PostgreSQL: A powerful, open-source relational database.
node-cron: A simple cron-like job scheduler for Node.js. Chosen for its simplicity in scheduling tasks within the Node.js process.- TypeScript: For type safety and improved maintainability.
System Architecture:
graph LR
A[User Browser] -- HTTPS --> B(RedwoodJS Web Frontend);
B -- GraphQL API Call --> C(RedwoodJS API Backend);
C -- Prisma Client --> D[(PostgreSQL DB)];
C -- Schedules Job --> E(Node-Cron Scheduler);
E -- Triggers at Scheduled Time --> C;
C -- Infobip SDK --> F(Infobip SMS API);
F -- Sends SMS --> G(User's Phone);
subgraph RedwoodJS App
direction LR
B
C
E
endPrerequisites:
- Node.js (v20 or higher recommended, check RedwoodJS docs for specifics)
- Yarn (v1.22.21 or higher)
- An Infobip Account (Sign up at Infobip)
- Access to a PostgreSQL database (local or cloud-hosted)
- Basic familiarity with JavaScript/TypeScript, React, GraphQL, and terminal commands.
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:
bashnode -v yarn -vIf 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.bashyarn create redwood-app redwood-infobip-scheduler --typescript --git-initFollow the prompts (commit message, yarn install).
-
Navigate to Project Directory:
bashcd redwood-infobip-scheduler -
Configure 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:bash# .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.bashyarn workspace api add @infobip-api/sdk node-cron date-fns-tz yarn workspace api add -D @types/node-cron@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).- How to find Infobip Credentials:
- Log in to your Infobip account.
- Navigate to the ""Developers"" or ""API Keys"" section (this might vary slightly based on UI updates).
- Generate or copy an existing API Key.
- Note down your unique Base URL (e.g.,
xxxxx.api.infobip.com).
Add the following lines to your
.envfile:bash# .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). - How to find Infobip Credentials:
-
Initial Commit (if not done during setup):
bashgit 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.prisma// api/db/schema.prisma datasource db { provider = ""postgresql"" url = env(""DATABASE_URL"") } generator client { provider = ""prisma-client-js"" binaryTargets = ""native"" } model Appointment { id Int @id @default(autoincrement()) recipientName String? recipientPhone String // E.164 format recommended (e.g., +14155552671) message String scheduledAt DateTime // Store time in UTC timeZone String // IANA Time Zone Name (e.g., ""America/New_York"") status String @default(""PENDING"") // PENDING, SENT, FAILED infobipMessageId String? // Store Infobip's message ID after sending failureReason String? // Store reason if sending failed createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([status, scheduledAt]) // Index for finding pending future jobs }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.- Added
@@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:
bashyarn rw prisma migrate devWhen 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.
bashyarn rw g sdl AppointmentThis 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.typescript// 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 }, }) }- Added comments emphasizing backend validation in
createAppointmentandupdateAppointment. - Included basic E.164 validation example.
- Added
try...catcharoundscheduleAppointmentJobcalls to handle potential scheduling errors during create/update. - Added
requireAuth()comments as placeholders (uncomment/implement if auth is set up). - Initialized/reset
failureReason.
- Added comments emphasizing backend validation in
-
Implement Infobip Integration: Create/update the utility file for Infobip interactions.
typescript// 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 } } }- Handles missing credentials more gracefully (logs warning, prevents client init).
- Checks for client initialization before sending.
- Improved error message extraction from Infobip responses.
-
Implement Scheduling Logic: Create/update the file for the
node-cronscheduler, incorporating robust timezone handling and clarifying persistence limitations.typescript// 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(); */- Added
date-fns-tzimport and used it ingetCronPatternfor robust timezone handling. - Removed the potentially ambiguous
timezoneoption fromcron.schedule. - Strengthened the warning about the in-memory
scheduledJobsmap and its limitations, mentioning persistent queues earlier. - Improved the comments regarding where and why
initializeSchedulermust be called, strongly advising against the GraphQL context and recommendingapi/src/server.tsor similar startup hooks. Added a conceptual example. - Added error handling within
initializeSchedulerloop. executeJobnow refetches the appointment for latest status.
- Added
4. Building the Frontend
Let's create the UI for managing appointments using RedwoodJS Cells and Forms.
-
Generate Page and Cell:
bashyarn rw g page Appointments /appointments yarn rw g cell AppointmentsThis 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.typescript// 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> ) }- Added
failureReasonandinfobipMessageIdto theQUERY. - Included placeholder comments in the
Successcomponent for where the list/table and form would go. - Note: The full implementation of the list display and integration with
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.
- Added
Frequently Asked Questions
How to schedule SMS reminders with RedwoodJS?
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.
What is RedwoodJS used for in this project?
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.
Why use Infobip for SMS delivery?
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.
When should I use date-fns-tz for timezones?
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.
How to integrate Infobip API into RedwoodJS?
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.
What database is used for storing appointments?
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.
What is node-cron and its purpose?
`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.
How to handle timezones with node-cron?
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.
How to set up the RedwoodJS project?
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.
What is the role of Prisma in this application?
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.
How to create the database schema?
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.
How to send SMS messages with Infobip?
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.
Can I reschedule appointments in this application?
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.
What are the prerequisites for this project?
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.