code examples

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

Build Production-Ready SMS Appointment Scheduling & Reminders with NestJS and Vonage

A guide to building an SMS appointment scheduling and reminder system using NestJS, Vonage, PostgreSQL, and Prisma, including setup, API implementation, and scheduling.

This guide provides a complete walkthrough for building a robust SMS appointment scheduling and reminder system using NestJS, Node.js, PostgreSQL, Prisma, and the Vonage Messages API. You will learn how to create an API endpoint to schedule appointments, store them in a database, and automatically send SMS reminders at a specified time before the appointment.

We aim to build a reliable backend service capable of handling appointment creation and triggering timely SMS notifications. This solves the common business need for reducing no-shows and keeping users informed about their upcoming appointments.

Key Technologies:

  • Node.js: The JavaScript runtime environment.
  • NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Its modular architecture, built-in dependency injection, and scheduling capabilities make it ideal.
  • Vonage Messages API: Used for sending SMS messages programmatically.
  • PostgreSQL: A powerful, open-source relational database.
  • Prisma: A modern database toolkit including an ORM (Object-Relational Mapper) for Node.js and TypeScript.
  • @nestjs/schedule: For running scheduled tasks (cron jobs) within NestJS to trigger reminders.
  • @nestjs/config: For managing environment variables securely.
  • class-validator & class-transformer: For robust request data validation.

System Architecture:

mermaid
graph LR
    A[User/Client App] -- HTTP POST --> B(NestJS API /appointments);
    B -- Creates Appointment --> C{PostgreSQL Database};
    B -- Schedules Reminder --> D(NestJS Scheduler @Cron);
    D -- Queries DB for due reminders --> C;
    D -- Triggers SMS --> E(Vonage Messages API);
    E -- Sends SMS --> F[User's Phone];
    C -- Stores Appointment Data & Reminder Status --> C;
    B -- Returns Success/Error --> A;

    style B fill:#f9f,stroke:#333,stroke-width:2px
    style C fill:#ccf,stroke:#333,stroke-width:2px
    style D fill:#ff9,stroke:#333,stroke-width:2px
    style E fill:#f6e,stroke:#333,stroke-width:2px

Final Outcome & Prerequisites:

By the end of this guide, you will have a functional NestJS application with:

  • An API endpoint (POST /appointments) to schedule new appointments.
  • Database integration (PostgreSQL via Prisma) to store appointment details.
  • Automated SMS reminders sent via Vonage before the scheduled appointment time.
  • Configuration management, validation, error handling, and basic security.

Prerequisites:

  • Node.js (LTS version recommended) and npm or yarn installed.
  • Access to a PostgreSQL database (local or cloud-hosted).
  • A Vonage API account.
  • Vonage CLI installed (npm install -g @vonage/cli).
  • ngrok installed (optional, for testing inbound webhooks if needed later).
  • Basic understanding of TypeScript, NestJS concepts, and REST APIs.
  • Docker and Docker Compose (optional, for easy local database setup).

1. Setting up the Project

Let's initialize our NestJS project and install the necessary dependencies.

1.1 Install NestJS CLI:

If you don't have it installed globally, run:

bash
npm install -g @nestjs/cli

1.2 Create New NestJS Project:

bash
nest new vonage-sms-scheduler
cd vonage-sms-scheduler

Choose your preferred package manager (npm or yarn) when prompted.

1.3 Install Dependencies:

We need several packages for configuration, scheduling, database interaction, validation, and the Vonage SDK.

bash
# Install runtime dependencies
npm install @nestjs/config @nestjs/schedule @vonage/server-sdk class-validator class-transformer @prisma/client @nestjs/terminus @nestjs/axios

# Install development dependencies
npm install --save-dev prisma typescript @nestjs/testing supertest jest @types/jest @types/supertest ts-jest ts-loader ts-node @types/express
  • @nestjs/config: For environment variable management.
  • @nestjs/schedule: For scheduling reminder jobs (uses node-cron internally).
  • @vonage/server-sdk: The official Vonage Node.js library.
  • class-validator, class-transformer: For input validation using DTOs.
  • @prisma/client, prisma: Prisma client and CLI for database interactions.
  • @nestjs/terminus, @nestjs/axios: For implementing health check endpoints.

1.4 Set up Prisma:

Initialize Prisma in your project:

bash
npx prisma init --datasource-provider postgresql

This creates:

  • A prisma directory with a schema.prisma file.
  • A .env file at the project root (if it doesn't exist).

1.5 Configure Environment Variables (.env):

Open the .env file created by Prisma and add your database connection string and Vonage API credentials. Never commit this file to version control. Create a .env.example file to track necessary variables.

.env:

dotenv
# ---------------------
# DATABASE
# ---------------------
# Example for PostgreSQL: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
DATABASE_URL="postgresql://your_db_user:your_db_password@localhost:5432/sms_scheduler?schema=public"

# ---------------------
# VONAGE API
# ---------------------
# Get these from your Vonage Dashboard -> API Settings
VONAGE_API_KEY="YOUR_VONAGE_API_KEY"
VONAGE_API_SECRET="YOUR_VONAGE_API_SECRET"

# ---------------------
# VONAGE APPLICATION
# ---------------------
# Create an application in the Vonage Dashboard or via CLI
# (See Vonage Integration section for details)
VONAGE_APPLICATION_ID="YOUR_VONAGE_APPLICATION_ID"
# Path relative to project root where the private key file is stored
# NOTE: For production, manage this key securely (e.g., volume mount, env var content), don't copy it into the image.
VONAGE_PRIVATE_KEY_PATH="./private.key"
VONAGE_SMS_FROM_NUMBER="YOUR_VONAGE_VIRTUAL_NUMBER" # Must be in E.164 format, e.g., 12015550123

# ---------------------
# APPLICATION
# ---------------------
# Port the NestJS app will run on
PORT=3000
# Reminder time in minutes before the appointment
REMINDER_LEAD_TIME_MINUTES=60

.env.example (Commit this file):

dotenv
# ---------------------
# DATABASE
# ---------------------
DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public"

# ---------------------
# VONAGE API
# ---------------------
VONAGE_API_KEY=""
VONAGE_API_SECRET=""

# ---------------------
# VONAGE APPLICATION
# ---------------------
VONAGE_APPLICATION_ID=""
VONAGE_PRIVATE_KEY_PATH="./private.key"
VONAGE_SMS_FROM_NUMBER="" # Must be in E.164 format, e.g., 12015550123

# ---------------------
# APPLICATION
# ---------------------
PORT=3000
REMINDER_LEAD_TIME_MINUTES=60

Note: Ensure your VONAGE_SMS_FROM_NUMBER is a number purchased from Vonage and linked to your application (see Section 4). It should be in E.164 format (e.g., 14155552671).

1.6 Project Structure (Conceptual):

NestJS encourages a modular structure. We'll create modules for different concerns:

src/ ├── app.module.ts # Root module ├── main.ts # Application entry point ├── config/ # Configuration setup (optional explicit module, used via @nestjs/config) ├── database/ # Prisma setup │ ├── prisma.module.ts │ └── prisma.service.ts ├── vonage/ # Vonage integration │ ├── vonage.module.ts │ └── vonage.service.ts ├── appointments/ # Core feature: Appointments │ ├── appointments.module.ts │ ├── appointments.controller.ts │ ├── appointments.service.ts │ ├── dto/ │ │ └── create-appointment.dto.ts │ └── entities/ # Not strictly needed with Prisma models ├── scheduler/ # Reminder scheduling task │ ├── scheduler.module.ts │ └── reminder.task.service.ts └── health/ # Health check endpoint ├── health.module.ts └── health.controller.ts prisma/ ├── schema.prisma └── migrations/ .env .env.example .gitignore tsconfig.json ...

We will create these modules and files in the subsequent steps.


2. Implementing Core Functionality (Appointments & Scheduling)

Now, let's build the core logic for creating appointments and scheduling reminders.

2.1 Create Prisma Module:

This module provides the Prisma service for database interactions throughout the application.

bash
nest g module database
nest g service database --no-spec

src/database/prisma.service.ts:

typescript
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    // Prisma recommends connecting explicitly in serverless environments,
    // but it's good practice generally.
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

src/database/prisma.module.ts:

typescript
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global() // Make PrismaService available globally
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

Register PrismaModule in AppModule:

src/app.module.ts:

typescript
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; // Import ConfigModule
import { PrismaModule } from './database/prisma.module';
// Other imports will be added later

@Module({
  imports: [
    ConfigModule.forRoot({ // Load .env file
      isGlobal: true, // Make config available globally
    }),
    PrismaModule,
    // Other modules will be added here
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

2.2 Create Vonage Module:

Encapsulate Vonage SDK interaction within its own module and service.

bash
nest g module vonage
nest g service vonage --no-spec

src/vonage/vonage.service.ts:

typescript
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Vonage } from '@vonage/server-sdk';
import { MessageSendRequest } from '@vonage/messages'; // Import specific type
import * as fs from 'fs'; // Import fs for reading the private key

@Injectable()
export class VonageService implements OnModuleInit {
  private readonly logger = new Logger(VonageService.name);
  private vonage: Vonage;
  private smsFromNumber: string;

  constructor(private configService: ConfigService) {}

  onModuleInit() {
    const apiKey = this.configService.get<string>('VONAGE_API_KEY');
    const apiSecret = this.configService.get<string>('VONAGE_API_SECRET');
    const applicationId = this.configService.get<string>('VONAGE_APPLICATION_ID');
    const privateKeyPath = this.configService.get<string>('VONAGE_PRIVATE_KEY_PATH');
    this.smsFromNumber = this.configService.get<string>('VONAGE_SMS_FROM_NUMBER');

    if (!apiKey || !apiSecret || !applicationId || !privateKeyPath || !this.smsFromNumber) {
      this.logger.error('Vonage API credentials or Application ID/Private Key path/From Number are missing in environment variables.');
      // Optionally throw an error to prevent app start
      // throw new Error('Missing Vonage configuration');
      return; // Prevent initialization if config is missing
    }

    try {
      // Read the private key file content
      // SECURITY NOTE: Reading from file system path is suitable for local dev/simpler setups.
      // In production, prefer injecting the key content via environment variables or a secure secret management system (like Kubernetes Secrets mounted as files/env vars).
      const privateKey = fs.readFileSync(privateKeyPath, 'utf8');

      this.vonage = new Vonage({
        apiKey: apiKey, // Include API key/secret for potential fallback or other API uses
        apiSecret: apiSecret,
        applicationId: applicationId,
        privateKey: privateKey, // Pass the key content, not the path
      });
      this.logger.log('Vonage SDK Initialized Successfully.');

    } catch (error) {
        this.logger.error(`Failed to read private key file at ${privateKeyPath} or initialize Vonage SDK: ${error.message}`);
        // throw new Error('Failed to initialize Vonage SDK');
    }
  }

  async sendSms(to: string, text: string): Promise<string | null> {
    if (!this.vonage) {
        this.logger.error('Vonage SDK not initialized. Cannot send SMS.');
        return null;
    }
    if (!to || !text) {
        this.logger.warn('Missing recipient (to) or message text. SMS not sent.');
        return null;
    }
    // Ensure 'to' number is in E.164 format if possible, though Vonage might handle some variations
    // Add basic validation or transformation if needed. Example:
    const formattedTo = to.startsWith('+') ? to.replace(/\s+/g, '') : `+${to.replace(/\s+/g, '')}`;

    const message: MessageSendRequest = {
      message_type: 'text',
      to: formattedTo, // Use validated/formatted number
      from: this.smsFromNumber,
      channel: 'sms',
      text: text,
    };

    try {
      const response = await this.vonage.messages.send(message);
      this.logger.log(`SMS sent to ${formattedTo}. Message UUID: ${response.message_uuid}`);
      return response.message_uuid; // Return message ID on success
    } catch (error) {
      this.logger.error(`Failed to send SMS to ${formattedTo}: ${error.message}`, error.stack);
      // Log detailed error if available from Vonage response
      if (error.response && error.response.data) {
          this.logger.error(`Vonage Error Details: ${JSON.stringify(error.response.data)}`);
      }
      return null; // Indicate failure
    }
  }
}

src/vonage/vonage.module.ts:

typescript
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; // Import ConfigModule
import { VonageService } from './vonage.service';

@Module({
  imports: [ConfigModule], // Ensure ConfigService is available
  providers: [VonageService],
  exports: [VonageService], // Export service for other modules to use
})
export class VonageModule {}

Register VonageModule in AppModule:

src/app.module.ts:

typescript
// ... other imports
import { VonageModule } from './vonage/vonage.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    PrismaModule,
    VonageModule, // Add VonageModule
    // Other modules will be added here
  ],
  // ... rest of the module definition
})
export class AppModule {}

2.3 Create Appointments Module:

This module handles the creation and management of appointments.

bash
nest g module appointments
nest g controller appointments --no-spec
nest g service appointments --no-spec

Define Data Transfer Object (DTO) for Validation:

src/appointments/dto/create-appointment.dto.ts:

typescript
import {
  IsNotEmpty,
  IsString,
  IsPhoneNumber,
  IsDateString,
  MinLength,
  IsOptional,
} from 'class-validator';

export class CreateAppointmentDto {
  @IsNotEmpty()
  @IsString()
  @MinLength(2)
  patientName: string;

  // Use IsPhoneNumber from class-validator. Ensure you provide it in E.164 format (e.g., +12015550123)
  @IsNotEmpty()
  @IsPhoneNumber(null) // null means region code is not fixed
  patientPhone: string;

  @IsNotEmpty()
  @IsDateString() // Validates ISO8601 date string e.g., ""2025-12-31T14:30:00.000Z""
  appointmentTime: string; // Use ISO 8601 format (UTC recommended)

  @IsOptional()
  @IsString()
  notes?: string;
}

Implement Appointments Service:

src/appointments/appointments.service.ts:

typescript
import { Injectable, Logger, InternalServerErrorException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../database/prisma.service';
import { CreateAppointmentDto } from './dto/create-appointment.dto';
import { Appointment } from '@prisma/client'; // Import Prisma's generated Appointment type
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AppointmentsService {
  private readonly logger = new Logger(AppointmentsService.name);
  private readonly reminderLeadTimeMinutes: number;

  constructor(
      private prisma: PrismaService,
      private configService: ConfigService,
  ) {
      this.reminderLeadTimeMinutes = parseInt(
          this.configService.get<string>('REMINDER_LEAD_TIME_MINUTES', '60'), // Default 60 mins
          10,
      );
  }

  async create(createAppointmentDto: CreateAppointmentDto): Promise<Appointment> {
    const { patientName, patientPhone, appointmentTime, notes } = createAppointmentDto;

    // Ensure appointmentTime is a valid Date object (ideally in UTC)
    const appointmentDateTime = new Date(appointmentTime);
    if (isNaN(appointmentDateTime.getTime())) {
        // ValidationPipe should catch IsDateString, but double-check prevents runtime errors if pipe disabled
        this.logger.error(`Invalid date format received: ${appointmentTime}`);
        throw new BadRequestException('Invalid appointmentTime format. Please use ISO 8601 format (UTC).');
    }

    // Calculate reminder time (subtract minutes from appointment time)
    const reminderTime = new Date(appointmentDateTime.getTime());
    reminderTime.setMinutes(reminderTime.getMinutes() - this.reminderLeadTimeMinutes);

    this.logger.log(`Scheduling appointment for ${patientName} at ${appointmentDateTime.toISOString()}, reminder at ${reminderTime.toISOString()}`);

    try {
        const newAppointment = await this.prisma.appointment.create({
            data: {
                patientName,
                // Store phone number consistently (e.g., keep E.164 format from validation)
                patientPhone: patientPhone,
                appointmentTime: appointmentDateTime, // Store as Date object
                reminderTime: reminderTime,          // Store calculated reminder time
                notes,
                status: 'SCHEDULED', // Initial status
                // reminderSentAt will be null initially
            },
        });
        this.logger.log(`Appointment created with ID: ${newAppointment.id}`);
        return newAppointment;
    } catch (error) {
        this.logger.error(`Failed to create appointment: ${error.message}`, error.stack);
        // Consider Prisma error codes for more specific errors (e.g., unique constraint violation)
        throw new InternalServerErrorException('Could not create appointment.');
    }
  }

  async findDueReminders(checkTime: Date): Promise<Appointment[]> {
      const lookAheadMinutes = 2; // Look slightly ahead to catch jobs running near the minute boundary
      const futureCheckTime = new Date(checkTime.getTime() + lookAheadMinutes * 60 * 1000);

      this.logger.log(`Checking for reminders due between now and ${futureCheckTime.toISOString()}`);

      try {
          const dueAppointments = await this.prisma.appointment.findMany({
              where: {
                  reminderTime: {
                      lte: futureCheckTime, // Reminder time is less than or equal to the near future time
                  },
                  status: 'SCHEDULED',     // Only scheduled appointments
                  reminderSentAt: null,    // Reminder not yet sent
              },
          });
          this.logger.log(`Found ${dueAppointments.length} appointments needing reminders.`);
          return dueAppointments;
      } catch (error) {
          this.logger.error(`Error fetching due reminders: ${error.message}`, error.stack);
          return []; // Return empty array on error to prevent scheduler crash
      }
  }

  async markReminderSent(appointmentId: string): Promise<Appointment | null> {
      try {
          const updatedAppointment = await this.prisma.appointment.update({
              where: { id: appointmentId },
              data: {
                  reminderSentAt: new Date(), // Mark as sent now
                  status: 'REMINDER_SENT',   // Update status
              },
          });
          this.logger.log(`Marked reminder as sent for appointment ID: ${appointmentId}`);
          return updatedAppointment;
      } catch (error) {
          // Handle potential errors, e.g., appointment not found (Prisma P2025 error code)
          this.logger.error(`Error marking reminder sent for appointment ID ${appointmentId}: ${error.message}`, error.stack);
          return null;
      }
  }
}

Implement Appointments Controller:

src/appointments/appointments.controller.ts:

typescript
import { Controller, Post, Body, ValidationPipe, UsePipes, Logger, HttpCode, HttpStatus, InternalServerErrorException, BadRequestException } from '@nestjs/common';
import { AppointmentsService } from './appointments.service';
import { CreateAppointmentDto } from './dto/create-appointment.dto';
import { Appointment } from '@prisma/client';

@Controller('appointments')
export class AppointmentsController {
  private readonly logger = new Logger(AppointmentsController.name);

  constructor(private readonly appointmentsService: AppointmentsService) {}

  @Post()
  @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) // Enable validation
  @HttpCode(HttpStatus.CREATED) // Set success status code to 201
  async create(@Body() createAppointmentDto: CreateAppointmentDto): Promise<Appointment> {
    this.logger.log(`Received request to create appointment: ${JSON.stringify(createAppointmentDto)}`);
    // Add try-catch for better error response to the client
    try {
        const appointment = await this.appointmentsService.create(createAppointmentDto);
        return appointment;
    } catch (error) {
        // Log the original error but throw a generic one or map specific service errors
        this.logger.error(`Error creating appointment in controller: ${error.message}`, error.stack);
        // Re-throw the exception caught from the service, or map it if needed.
        // If it's already an HttpException from the service, it will be handled correctly.
        // If it's an unexpected error, throw a generic InternalServerErrorException.
        if (error instanceof InternalServerErrorException || error instanceof BadRequestException) {
            throw error;
        }
        throw new InternalServerErrorException('An unexpected error occurred while creating the appointment.');
    }
  }
}

Configure Appointments Module:

src/appointments/appointments.module.ts:

typescript
import { Module } from '@nestjs/common';
import { AppointmentsService } from './appointments.service';
import { AppointmentsController } from './appointments.controller';
// PrismaModule is global, VonageModule needs to be imported if used directly here,
// but AppointmentsService gets it via DI since VonageModule is registered in AppModule.
// ConfigModule is also global.

@Module({
  // No need to import PrismaModule or ConfigModule here as they are global
  controllers: [AppointmentsController],
  providers: [AppointmentsService],
  exports: [AppointmentsService], // Export if other modules need it (like the scheduler)
})
export class AppointmentsModule {}

Register AppointmentsModule in AppModule:

src/app.module.ts:

typescript
// ... other imports
import { AppointmentsModule } from './appointments/appointments.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    PrismaModule,
    VonageModule,
    AppointmentsModule, // Add AppointmentsModule
    // SchedulerModule will be added next
  ],
  // ... rest of the module definition
})
export class AppModule {}

2.4 Create Scheduler Module for Reminders:

This module will contain the scheduled task (cron job) that checks for due reminders and triggers SMS sending.

bash
nest g module scheduler
nest g service scheduler/ReminderTask --flat --no-spec
# Use --flat to avoid creating a sub-directory
# Rename scheduler.service.ts to reminder.task.service.ts if needed

src/scheduler/reminder.task.service.ts:

typescript
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { AppointmentsService } from '../appointments/appointments.service';
import { VonageService } from '../vonage/vonage.service';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class ReminderTaskService {
  private readonly logger = new Logger(ReminderTaskService.name);
  private readonly reminderLeadTimeMinutes: number;

  constructor(
    private appointmentsService: AppointmentsService,
    private vonageService: VonageService,
    private configService: ConfigService,
  ) {
     this.reminderLeadTimeMinutes = parseInt(
         this.configService.get<string>('REMINDER_LEAD_TIME_MINUTES', '60'),
         10,
     );
  }

  // Run this job every minute
  @Cron(CronExpression.EVERY_MINUTE)
  async handleCron() {
    this.logger.log('Running reminder check cron job...');
    const now = new Date(); // Use UTC time

    const dueAppointments = await this.appointmentsService.findDueReminders(now);

    if (dueAppointments.length === 0) {
      this.logger.log('No reminders due at this time.');
      return;
    }

    this.logger.log(`Processing ${dueAppointments.length} reminders...`);

    for (const appointment of dueAppointments) {
      // NOTE: toLocaleString() uses the server's locale/timezone. This can lead to inconsistencies.
      // For reliable timezone handling suitable for user-facing messages, see the discussion and
      // examples using libraries like date-fns-tz in Section 8.1.
      const reminderText = `Hi ${appointment.patientName}, this is a reminder for your appointment scheduled at ${appointment.appointmentTime.toLocaleString()}.`;

      this.logger.log(`Attempting to send reminder for appointment ID: ${appointment.id} to ${appointment.patientPhone}`);

      const messageUuid = await this.vonageService.sendSms(
        appointment.patientPhone,
        reminderText,
      );

      if (messageUuid) {
        // Mark reminder as sent in DB only if SMS sending was successful (got UUID)
        await this.appointmentsService.markReminderSent(appointment.id);
        this.logger.log(`Reminder SMS sent successfully for appointment ID: ${appointment.id}, Vonage Message UUID: ${messageUuid}`);
      } else {
        // Handle failed SMS send - log is already done in vonageService
        // Consider adding retry logic here or marking the appointment as failed_reminder
        this.logger.warn(`Failed to send reminder SMS for appointment ID: ${appointment.id}. It will be retried on the next run if not marked as sent.`);
        // Optionally update status to 'REMINDER_FAILED' after N retries
      }
    }
    this.logger.log('Finished processing reminders.');
  }
}

src/scheduler/scheduler.module.ts:

typescript
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule'; // Import NestJS Schedule Module
import { ReminderTaskService } from './reminder.task.service';
import { AppointmentsModule } from '../appointments/appointments.module'; // Import AppointmentsModule to access AppointmentsService
import { VonageModule } from '../vonage/vonage.module'; // Import VonageModule
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ScheduleModule.forRoot(), // Initialize the scheduler
    AppointmentsModule,       // Make AppointmentsService available
    VonageModule,             // Make VonageService available
    ConfigModule,             // Make ConfigService available
  ],
  providers: [ReminderTaskService],
})
export class AppSchedulerModule {} // Renamed to avoid conflict with NestJS ScheduleModule

Register AppSchedulerModule in AppModule:

src/app.module.ts:

typescript
// ... other imports
import { AppSchedulerModule } from './scheduler/scheduler.module'; // Use the new name

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    PrismaModule,
    VonageModule,
    AppointmentsModule,
    AppSchedulerModule, // Add AppSchedulerModule
    // HealthModule will be added later
  ],
  // ... rest of the module definition
})
export class AppModule {}

2.5 Enable Validation Pipe Globally:

To ensure all incoming requests are validated against DTOs, enable the ValidationPipe globally.

src/main.ts:

typescript
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe, Logger } from '@nestjs/common'; // Import Logger
import { ConfigService } from '@nestjs/config'; // Import ConfigService

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const configService = app.get(ConfigService); // Get ConfigService instance
  const port = configService.get<number>('PORT', 3000); // Get port from env, default 3000
  const logger = new Logger('Bootstrap'); // Create a logger instance

  // Enable global validation pipes
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true, // Strip properties that do not have any decorators
      forbidNonWhitelisted: true, // Throw an error if non-whitelisted values are provided
      transform: true, // Automatically transform payloads to DTO instances
      transformOptions: {
        enableImplicitConversion: true, // Allow basic type conversions
      },
    }),
  );

  // Enable CORS if needed (adjust origin as necessary for production)
  app.enableCors({
      origin: '*', // Be more specific in production!
      methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
      credentials: true,
  });


  await app.listen(port);
  logger.log(`Application is running on: ${await app.getUrl()}`);
  logger.log(`Reminder lead time set to: ${configService.get('REMINDER_LEAD_TIME_MINUTES')} minutes`);
  logger.log(`Vonage App ID: ${configService.get('VONAGE_APPLICATION_ID')}`);
  logger.log(`Vonage From Number: ${configService.get('VONAGE_SMS_FROM_NUMBER')}`);

}
bootstrap();

3. Building the API Layer

The primary API endpoint is POST /appointments, which we created in the previous section.

Endpoint Definition:

  • Method: POST
  • Path: /appointments
  • Description: Creates a new appointment and schedules an SMS reminder.
  • Request Body: CreateAppointmentDto (JSON)
  • Success Response: 201 Created with the created Appointment object (JSON).
  • Error Responses:
    • 400 Bad Request: If validation fails (e.g., invalid phone number, missing fields).
    • 500 Internal Server Error: If database operation or other internal process fails.

Request Example (JSON):

json
{
  "patientName": "Alice Wonderland",
  "patientPhone": "+12025550149",
  "appointmentTime": "2025-08-15T10:00:00.000Z",
  "notes": "Checkup"
}

Response Example (JSON - Success 201 Created):

json
{
  "id": "clxkpz4v00000unrp6hgz7r8q",
  "patientName": "Alice Wonderland",
  "patientPhone": "+12025550149",
  "appointmentTime": "2025-08-15T10:00:00.000Z",
  "reminderTime": "2025-08-15T09:00:00.000Z",
  "notes": "Checkup",
  "status": "SCHEDULED",
  "reminderSentAt": null,
  "createdAt": "2025-04-20T12:00:00.000Z",
  "updatedAt": "2025-04-20T12:00:00.000Z"
}

Testing with curl:

Replace placeholders with your actual data and running application URL (e.g., http://localhost:3000).

bash
curl -X POST http://localhost:3000/appointments \
-H "Content-Type: application/json" \
-d '{
  "patientName": "Bob The Builder",
  "patientPhone": "+16505550162",
  "appointmentTime": "2025-09-20T15:30:00.000Z",
  "notes": "Follow-up visit"
}'

4. Integrating with Vonage

This involves setting up your Vonage account, application, and number correctly.

4.1 Vonage Account & Credentials:

  • Sign up for a Vonage API account if you haven't already.
  • Navigate to your Vonage API Dashboard.
  • Find your API Key and API Secret at the top of the dashboard. Add these to your .env file (VONAGE_API_KEY, VONAGE_API_SECRET).

4.2 Create a Vonage Application:

An application acts as a container for your configurations and links your virtual number. You can create this via the Dashboard or the CLI. Using the CLI is often faster for developers.

  • Authenticate CLI (if first time):

    bash
    vonage config:set --apiKey=YOUR_API_KEY --apiSecret=YOUR_API_SECRET
  • Create Application: We only need the Messages capability for sending SMS. Webhooks aren't strictly required for this guide's core functionality (sending reminders), but you might enable them if you plan to handle inbound messages later.

    bash
    # Create an app with only Messages capability (no webhooks needed for sending)
    # Replace ""My SMS Scheduler App"" with your desired name
    vonage apps:create ""My SMS Scheduler App"" --capabilities=messages --messages_version=v1 --keyfile=private.key
    
    # This command will:
    # 1. Create the application on Vonage.
    # 2. Output the Application ID (copy this to VONAGE_APPLICATION_ID in .env).
    # 3. Generate a private key file (private.key) in your current directory.
    #    Ensure VONAGE_PRIVATE_KEY_PATH in .env points to this file (e.g., ./private.key).
    #    Remember to handle this key securely in production!

4.3 Purchase and Link a Vonage Number:

You need a Vonage virtual number to send SMS messages from.

  • Search for Available Numbers (Example: US):
    bash
    vonage numbers:search US --features=SMS
  • Buy a Number: Choose a number from the list and buy it.
    bash
    # Replace <NUMBER> with the chosen number from the search results
    vonage numbers:buy <NUMBER> US
  • Link the Number to Your Application:
    bash
    # Replace <NUMBER> and <APP_ID> with your purchased number and the Application ID from step 4.2
    vonage apps:link <APP_ID> --number=<NUMBER>
  • Update .env: Add the purchased number (in E.164 format, e.g., 12015550123) to VONAGE_SMS_FROM_NUMBER in your .env file.

4.4 Configure Private Key:

  • The vonage apps:create command generated a private.key file.
  • Ensure the VONAGE_PRIVATE_KEY_PATH in your .env file correctly points to the location of this file relative to your project root (e.g., ./private.key).
  • Security: Add private.key to your .gitignore file. In production, manage this key securely (e.g., environment variable content, secrets manager, mounted volume) rather than committing it or copying it directly into container images.

Your Vonage integration setup is now complete. The VonageService uses these credentials and the private key to authenticate with the Vonage API for sending SMS messages.

Frequently Asked Questions

How to schedule SMS appointment reminders with NestJS?

Use NestJS's `@nestjs/schedule` module along with the Vonage Messages API. Create a scheduled task (cron job) that queries your database for upcoming appointments and sends SMS reminders via the Vonage API at the designated time before the appointment. This guide provides a complete walkthrough using PostgreSQL, Prisma, and other key technologies.

What is NestJS used for in appointment scheduling?

NestJS provides a structured, scalable backend framework. It facilitates API endpoint creation for scheduling, database integration with PostgreSQL via Prisma, and scheduled task management for sending SMS reminders through the Vonage Messages API. Its modular architecture and dependency injection simplify development and maintenance.

Why use Vonage Messages API for appointment reminders?

The Vonage Messages API allows you to programmatically send SMS messages. This is ideal for appointment reminders as it enables automation and ensures timely notifications to reduce no-shows and improve communication with users.

What database is recommended for storing appointment data?

This guide uses PostgreSQL, a robust and open-source relational database, combined with Prisma, a modern database toolkit and ORM. Prisma simplifies database interactions and data modeling within the NestJS application.

When should I use class-validator and class-transformer in NestJS?

These packages are crucial for request data validation. `class-validator` provides decorators for defining validation rules (e.g., required fields, data types, string length). `class-transformer` facilitates transforming request payloads into data transfer objects (DTOs), enforcing data integrity and security.

How to set up Vonage API credentials for SMS scheduling?

Obtain your API Key and API Secret from your Vonage API Dashboard. Store these securely in a `.env` file (never commit this to version control) and access them within your NestJS application using `@nestjs/config` for secure configuration management.

How to create a Vonage application for sending SMS messages?

Use the Vonage CLI to create a new application with the Messages capability. The command `vonage apps:create "My SMS Scheduler App" --capabilities=messages --messages_version=v1 --keyfile=private.key` sets up an application and generates a private key file (ensure this key is stored securely).

What is the purpose of a Vonage virtual number?

A Vonage virtual number is required to send SMS messages via the Vonage API. After purchasing and linking a Vonage number to your application, add it to your `.env` file (VONAGE_SMS_FROM_NUMBER) as the sender ID for your reminders. Ensure the number is in E.164 format (+12015550123).

How to handle the Vonage private key securely in production?

Never commit the `private.key` file to version control. Instead, manage it through a secure method such as environment variables, a dedicated secrets manager, or mounting it as a volume in containerized environments (e.g. Kubernetes secrets).

What is Prisma used for in this NestJS project?

Prisma is a modern database toolkit that includes an ORM (Object-Relational Mapper). It simplifies database interactions, schema management, and data modeling in your Node.js and TypeScript applications, making it easier to work with PostgreSQL or other supported databases.

How does the reminder scheduling work in the NestJS app?

The `@nestjs/schedule` module allows you to define cron jobs (scheduled tasks). A cron job runs every minute (using CronExpression.EVERY_MINUTE) to check the database for appointments whose reminder time has passed but haven't had a reminder sent. If found, the app sends an SMS using the Vonage Messages API.

What's the role of @nestjs/config in the SMS scheduler?

The `@nestjs/config` module provides a way to manage environment variables and application configuration securely. It helps access sensitive data like database credentials and API keys from a `.env` file without exposing them directly in the codebase. Create a `.env.example` file to track these environment variables.