code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

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:

  1. Web Interface: A frontend built with RedwoodJS (React) to create, view, and manage scheduled SMS messages (appointments).
  2. API Backend: A RedwoodJS API (GraphQL) to handle data persistence (using Prisma and PostgreSQL) and interact with the scheduling and SMS sending logic.
  3. SMS Sending: Integration with the Infobip SMS API using their official Node.js SDK to dispatch messages.
  4. Scheduling: A reliable mechanism using node-cron running on the Node.js backend to trigger SMS sending at the correct time, accounting for different time zones.
  5. 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:

mermaid
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
    end

Prerequisites:

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

  1. Verify Node/Yarn Versions: Open your terminal and check your versions:

    bash
    node -v
    yarn -v

    If needed, update Node.js (using nvm is recommended: nvm install 20 && nvm use 20) and Yarn (npm install -g yarn).

  2. Create RedwoodJS App: Use the create-redwood-app command. We'll use TypeScript (default) and initialize a git repo.

    bash
    yarn create redwood-app redwood-infobip-scheduler --typescript --git-init

    Follow the prompts (commit message, yarn install).

  3. Navigate to Project Directory:

    bash
    cd redwood-infobip-scheduler
  4. Configure Database Connection: Locate the .env file in the project root. Update the DATABASE_URL variable 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, and schedulerdb with your actual database credentials and name.

  5. Install Dependencies: We need the Infobip SDK, node-cron for scheduling, date-fns-tz for robust timezone handling, and type definitions.

    bash
    yarn 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 for node-cron.
  6. Configure Infobip Credentials: Add your Infobip API Key and Base URL to the .env file. 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:
      1. Log in to your Infobip account.
      2. Navigate to the ""Developers"" or ""API Keys"" section (this might vary slightly based on UI updates).
      3. Generate or copy an existing API Key.
      4. Note down your unique Base URL (e.g., xxxxx.api.infobip.com).

    Add the following lines to your .env file:

    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 name

    Important: Replace YOUR_INFOBIP_API_KEY and YOUR_INFOBIP_BASE_URL with the actual credentials obtained from your Infobip account. These are sensitive secrets. Security Note: Never commit your .env file to version control. Ensure .env is listed in your .gitignore file (it should be by default in RedwoodJS).

  7. Initial Commit (if not done during setup):

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

  1. Define Prisma Schema: Open api/db/schema.prisma and define the Appointment model.

    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.
  2. Create and Apply Migration: Run the following command to generate the SQL migration file and apply it to your development database:

    bash
    yarn rw prisma migrate dev

    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.

  1. Generate GraphQL SDL and Service: Redwood's generators make this easy.

    bash
    yarn rw g sdl Appointment

    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.
  2. Implement CRUD Operations: Open api/src/services/appointments/appointments.ts and 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 createAppointment and updateAppointment.
    • Included basic E.164 validation example.
    • Added try...catch around scheduleAppointmentJob calls to handle potential scheduling errors during create/update.
    • Added requireAuth() comments as placeholders (uncomment/implement if auth is set up).
    • Initialized/reset failureReason.
  3. 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.
  4. Implement Scheduling Logic: Create/update the file for the node-cron scheduler, 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-tz import and used it in getCronPattern for robust timezone handling.
    • Removed the potentially ambiguous timezone option from cron.schedule.
    • Strengthened the warning about the in-memory scheduledJobs map and its limitations, mentioning persistent queues earlier.
    • Improved the comments regarding where and why initializeScheduler must be called, strongly advising against the GraphQL context and recommending api/src/server.ts or similar startup hooks. Added a conceptual example.
    • Added error handling within initializeScheduler loop.
    • executeJob now refetches the appointment for latest status.

4. Building the Frontend

Let's create the UI for managing appointments using RedwoodJS Cells and Forms.

  1. Generate Page and Cell:

    bash
    yarn rw g page Appointments /appointments
    yarn rw g cell Appointments

    This creates:

    • web/src/pages/AppointmentsPage/AppointmentsPage.tsx
    • web/src/components/AppointmentsCell/AppointmentsCell.{tsx,mock.ts,test.tsx}
  2. 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 failureReason and infobipMessageId to the QUERY.
    • Included placeholder comments in the Success component 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 with yarn rw g component AppointmentForm) is beyond the scope of this snippet but would be the next step here.

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.