code examples

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

Build a Production-Ready SMS Scheduler with NestJS and Infobip

A step-by-step guide to building an SMS scheduling application using NestJS, Prisma, PostgreSQL, and the Infobip API for timed message delivery.

This guide provides a step-by-step walkthrough for building a robust SMS scheduling and reminder application using the NestJS framework and the Infobip SMS API. We will cover everything from initial project setup to deployment considerations, enabling you to create a reliable system for sending timed SMS messages.

By the end of this tutorial, you will have a functional NestJS application capable of accepting API requests to schedule SMS messages, storing these schedules, interacting with the Infobip API to schedule and cancel messages, and providing endpoints to manage these scheduled messages. This solves the common business need for automated reminders, notifications, and time-sensitive alerts via SMS.

Project Overview and Goals

What We'll Build:

  • A NestJS backend application with a REST API.
  • Endpoints to:
    • Schedule a new SMS message for a future date/time.
    • Retrieve details of a specific scheduled SMS.
    • List all scheduled SMS messages (with optional status filtering).
    • Cancel a previously scheduled SMS.
  • Integration with the Infobip SMS API for actual scheduling and cancellation.
  • A database layer using Prisma and PostgreSQL to persist schedule information.
  • Proper configuration management, error handling, and validation.

Technologies Used:

  • Node.js: The JavaScript runtime environment.
  • NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Chosen for its modular architecture, dependency injection, and built-in support for features like validation and configuration.
  • Infobip: A cloud communications platform providing an SMS API. Chosen for its reliable delivery, scheduling features, and SDK support.
  • Infobip Node.js SDK (@infobip-api/sdk): Simplifies interaction with the Infobip API.
  • Prisma: A next-generation ORM for Node.js and TypeScript. Chosen for its type safety, auto-generated migrations, and developer-friendly API.
  • PostgreSQL: A powerful, open-source object-relational database system.
  • Docker (Optional): For containerizing the application and database for easier development and deployment.

System Architecture:

mermaid
graph LR
    A[Client / API Consumer] -- HTTPS Request --> B(NestJS API);
    B -- Schedule/Cancel/Query --> C(Scheduling Service);
    C -- Save/Read Schedule Data --> D[(PostgreSQL DB via Prisma)];
    C -- Schedule/Cancel SMS --> E(Infobip SMS API);
    E -- Sends SMS --> F(User's Phone);

Prerequisites:

  • Node.js (LTS version recommended, e.g., v18 or v20) and npm/yarn.
  • An Infobip Account (a free trial account is sufficient for this guide).
  • Access to a PostgreSQL database (local instance, Docker container, or cloud service).
  • Basic familiarity with TypeScript, REST APIs, and asynchronous programming.
  • Docker and Docker Compose (optional, but recommended for database setup).
  • A code editor like Visual Studio Code.

1. Setting up the Project

Let's initialize our NestJS project and configure the necessary dependencies and environment variables.

  1. Create a new NestJS Project: Open your terminal and run the NestJS CLI command:

    bash
    npx @nestjs/cli new nestjs-infobip-scheduler
    cd nestjs-infobip-scheduler

    When prompted, choose your preferred package manager (npm or yarn). We'll use npm in this guide.

  2. Install Dependencies: We need several packages for configuration, scheduling (within NestJS, though Infobip handles final scheduling), Infobip SDK, Prisma, validation, and date handling.

    bash
    # Core dependencies
    npm install @nestjs/config @infobip-api/sdk class-validator class-transformer date-fns date-fns-tz
    
    # Prisma dependencies
    npm install prisma @prisma/client
    npm install -D @types/node
    
    # Initialize Prisma
    npx prisma init --datasource-provider postgresql
    • @nestjs/config: Manages environment variables.
    • @infobip-api/sdk: The official Infobip SDK for Node.js.
    • class-validator, class-transformer: For request data validation using DTOs.
    • date-fns, date-fns-tz: Robust libraries for date and timezone manipulation.
    • prisma, @prisma/client: Prisma CLI and Client for database interaction.
  3. Configure Environment Variables: Prisma initialization created a prisma/ directory and a .env file. Update the .env file with your database connection string and placeholders for Infobip credentials.

    dotenv
    # .env
    
    # Database Configuration (adjust username, password, host, port, dbname)
    # Example for local PostgreSQL:
    DATABASE_URL=""postgresql://user:password@localhost:5432/schedulerdb?schema=public""
    
    # Infobip Credentials
    INFOBIP_BASE_URL=""YOUR_INFOBIP_BASE_URL"" # Get from your Infobip account dashboard (e.g., xyz.api.infobip.com)
    INFOBIP_API_KEY=""YOUR_INFOBIP_API_KEY""   # Get from your Infobip account API Keys section
    • DATABASE_URL: The connection string for your PostgreSQL database. Ensure the database (schedulerdb in the example) exists. If using Docker, adjust the host (e.g., postgres).
    • INFOBIP_BASE_URL: Your unique API base URL provided by Infobip. Find this in your Infobip account under API Keys or on the main dashboard after logging in.
    • INFOBIP_API_KEY: Your secret API key generated within the Infobip portal (API > API Keys). Keep this secure.
  4. Set up Configuration Module: Integrate the @nestjs/config module to load environment variables.

    typescript
    // src/app.module.ts
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    // Import other modules (Prisma, Scheduling) later
    
    @Module({
      imports: [
        ConfigModule.forRoot({
          isGlobal: true, // Make ConfigService available globally
        }),
        // Add PrismaModule and SchedulingModule here later
      ],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}
  5. Set up Database (Optional - Docker): If you don't have PostgreSQL running, you can use Docker Compose. Create a docker-compose.yml file:

    yaml
    # docker-compose.yml
    version: '3.8'
    services:
      postgres:
        image: postgres:15
        container_name: postgres_scheduler
        environment:
          POSTGRES_USER: user
          POSTGRES_PASSWORD: password
          POSTGRES_DB: schedulerdb
        ports:
          - ""5432:5432""
        volumes:
          - postgres_data:/var/lib/postgresql/data
    volumes:
      postgres_data:

    Run docker-compose up -d in your terminal to start the database container. Your DATABASE_URL in .env should match the credentials (user, password, schedulerdb) and point to localhost:5432.

2. Creating a Database Schema and Data Layer (Prisma)

We'll define the schema for storing scheduled SMS information using Prisma.

  1. Define the Prisma Schema: Open prisma/schema.prisma and define the ScheduledSms model.

    prisma
    // prisma/schema.prisma
    generator client {
      provider = ""prisma-client-js""
    }
    
    datasource db {
      provider = ""postgresql""
      url      = env(""DATABASE_URL"")
    }
    
    model ScheduledSms {
      id            String   @id @default(cuid())
      recipient     String   // Phone number in international format
      message       String
      scheduledAt   DateTime // The intended time for the message to be sent (UTC)
      status        Status   @default(PENDING)
      infobipBulkId String?  // ID returned by Infobip for the scheduled batch
      infobipMessageId String? // Optional: ID of the specific message if needed
      createdAt     DateTime @default(now())
      updatedAt     DateTime @updatedAt
    }
    
    enum Status {
      PENDING          // Scheduled in our DB, not yet sent to Infobip
      SCHEDULED_INFOBIP // Successfully scheduled via Infobip API
      SENT             // Confirmed sent by Infobip (requires webhooks - advanced)
      FAILED           // Failed to schedule or send
      CANCELED         // Canceled by user request
    }
    • We store recipient, message, and the target scheduledAt time (always store in UTC).
    • status tracks the lifecycle of the scheduled message.
    • infobipBulkId is crucial for referencing the scheduled message batch within Infobip for status updates or cancellation.
  2. Apply Schema Changes and Generate Prisma Client: Run the following commands in your terminal:

    bash
    # Apply schema changes to the database (creates the table)
    npx prisma db push
    
    # Generate the Prisma Client based on the schema
    npx prisma generate

    db push is suitable for development; for production, use prisma migrate dev and prisma migrate deploy. generate updates the @prisma/client library with type-safe methods for your schema.

  3. Create Prisma Service: Create a reusable service to interact with the Prisma Client.

    bash
    nest generate module prisma
    nest generate service prisma --flat # Create src/prisma.service.ts

    Implement the service to connect and disconnect Prisma cleanly:

    typescript
    // src/prisma.service.ts
    import { Injectable, OnModuleInit, INestApplication } from '@nestjs/common';
    import { PrismaClient } from '@prisma/client';
    
    @Injectable()
    export class PrismaService extends PrismaClient implements OnModuleInit {
      async onModuleInit() {
        // Optional: Connect explicitely
        await this.$connect();
      }
    
      async enableShutdownHooks(app: INestApplication) {
        process.on('beforeExit', async () => {
          await app.close();
        });
      }
    }

    Make the PrismaService available globally by exporting it from PrismaModule and importing PrismaModule into AppModule.

    typescript
    // src/prisma/prisma.module.ts
    import { Module, Global } from '@nestjs/common';
    import { PrismaService } from './prisma.service';
    
    @Global() // Make PrismaService available globally
    @Module({
      providers: [PrismaService],
      exports: [PrismaService],
    })
    export class PrismaModule {}
    typescript
    // src/app.module.ts
    // ... other imports
    import { ConfigModule } from '@nestjs/config'; // Ensure ConfigModule is imported
    import { PrismaModule } from './prisma/prisma.module';
    import { AppController } from './app.controller'; // Ensure AppController is imported
    import { AppService } from './app.service'; // Ensure AppService is imported
    // Import SchedulingModule later
    
    @Module({
      imports: [
        ConfigModule.forRoot({ isGlobal: true }),
        PrismaModule,
        // Add SchedulingModule here later
      ],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}

    Update src/main.ts to enable shutdown hooks for Prisma:

    typescript
    // src/main.ts
    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    import { PrismaService } from './prisma/prisma.service';
    import { ValidationPipe } from '@nestjs/common'; // Import later
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
    
      // Enable ValidationPipe globally (Add later)
      // app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
    
      // Enable Prisma shutdown hooks
      const prismaService = app.get(PrismaService);
      await prismaService.enableShutdownHooks(app);
    
      await app.listen(3000);
      console.log(`Application is running on: ${await app.getUrl()}`);
    }
    bootstrap();

3. Integrating with Infobip

Now, let's create a dedicated module and service to handle interactions with the Infobip API.

  1. Generate Infobip Module and Service:

    bash
    nest generate module infobip
    nest generate service infobip --flat # Create src/infobip.service.ts
  2. Implement Infobip Service: This service will initialize the Infobip client using credentials from the ConfigService and provide methods to interact with the SMS API.

    typescript
    // src/infobip.service.ts
    import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import { Infobip, AuthType } from '@infobip-api/sdk';
    import { SendSmsOptions } from './interfaces/infobip.interface'; // Create this interface
    
    @Injectable()
    export class InfobipService implements OnModuleInit {
      private readonly logger = new Logger(InfobipService.name);
      private infobipClient: Infobip;
    
      constructor(private configService: ConfigService) {}
    
      onModuleInit() {
        const apiKey = this.configService.get<string>('INFOBIP_API_KEY');
        const baseUrl = this.configService.get<string>('INFOBIP_BASE_URL');
    
        if (!apiKey || !baseUrl) {
          this.logger.error('Infobip API Key or Base URL missing in configuration.');
          throw new Error('Infobip configuration is incomplete.');
        }
    
        this.infobipClient = new Infobip({
          baseUrl: baseUrl,
          apiKey: apiKey,
          authType: AuthType.ApiKey,
        });
        this.logger.log('Infobip Client Initialized');
      }
    
      async scheduleSms(options: SendSmsOptions) {
        const { recipient, message, sendAt, bulkId } = options;
        this.logger.log(`Attempting to schedule SMS via Infobip for bulkId: ${bulkId}`);
    
        try {
          // Note: Infobip expects ISO 8601 format with timezone offset
          // 'date-fns-tz' helps ensure correct formatting if needed
          const formattedSendAt = sendAt.toISOString(); // Ensure sendAt is a Date object in UTC
    
          // NOTE: Verify this method signature and expected request structure against the current @infobip-api/sdk documentation.
          const response = await this.infobipClient.channels.sms.send({
             messages: [
               {
                 destinations: [{ to: recipient }],
                 text: message,
                 // from: 'YourSenderID', // Optional: Configure Sender ID in Infobip portal
                 sendAt: formattedSendAt, // Crucial for scheduling
               },
             ],
             bulkId: bulkId, // Assign our generated bulkId
          });
    
          this.logger.log(`Infobip schedule response for bulkId ${bulkId}: ${JSON.stringify(response.data)}`);
    
          // NOTE: Verify this response structure against the actual responses from the @infobip-api/sdk.
          if (response.data?.messages?.length > 0 && response.data.bulkId) {
             return {
               bulkId: response.data.bulkId,
               // Verify this response path exists in the actual SDK response.
               messageId: response.data.messages[0].messageId, // Assuming one message per bulk here
               status: response.data.messages[0].status,
             };
          } else {
             throw new Error('Unexpected response structure from Infobip schedule API');
          }
        } catch (error) {
          this.logger.error(`Failed to schedule SMS via Infobip for bulkId ${bulkId}: ${error.message}`, error.stack);
          // Enhance error handling: Check error details (e.g., error.response?.data for specific Infobip codes/messages) for more robust handling.
          throw new Error(`Infobip API error: ${error.message}`);
        }
      }
    
      // The following code for cancellation uses a method structure (`updateScheduledStatus`)
      // conceptually based on Infobip's Go SDK documentation for updating scheduled message status.
      // **It is critical to verify the exact method name, parameters, and expected response structure
      // using the official documentation for the `@infobip-api/sdk` Node.js package before using this in production.**
      async cancelScheduledSms(bulkId: string) {
        this.logger.log(`Attempting to cancel scheduled SMS via Infobip for bulkId: ${bulkId}`);
        try {
           // This functionality aligns with the Go SDK research.
           // We need the `updateScheduledMessagesStatus` equivalent.
           // Assuming the Node SDK might use a structure like this (VERIFY WITH SDK DOCS):
           const response = await this.infobipClient.channels.sms.updateScheduledStatus({
             bulkId: bulkId, // Query parameter to identify the bulk
             body: { status: 'CANCELED' } // Request body specifying the new status
           });
    
           this.logger.log(`Infobip cancel response for bulkId ${bulkId}: ${JSON.stringify(response.data)}`);
    
           // NOTE: Verify this response structure and condition against the actual responses from the @infobip-api/sdk.
           // Verify this response path exists in the actual SDK response.
           if (response.data?.bulkId && response.data?.status === 'CANCELED') {
               return { success: true, status: response.data.status };
           } else {
               throw new Error('Failed to confirm cancellation via Infobip API response.');
           }
        } catch (error) {
           this.logger.error(`Failed to cancel SMS via Infobip for bulkId ${bulkId}: ${error.message}`, error.stack);
           // Enhance error handling: Check error details (e.g., error.response?.data for specific Infobip codes/messages) for more robust handling.
           throw new Error(`Infobip API cancellation error: ${error.message}`);
        }
      }
    
      // Add methods for GetScheduledMessages, RescheduleMessages, GetStatus if needed,
      // mirroring the Go SDK research findings but using the Node SDK syntax.
      // Check the official @infobip-api/sdk documentation for exact method names and parameters.
    }
  3. Define Interface: Create an interface for the scheduleSms options.

    typescript
    // src/infobip/interfaces/infobip.interface.ts
    export interface SendSmsOptions {
      recipient: string;
      message: string;
      sendAt: Date; // Use Date object, ensures type safety
      bulkId: string; // The unique ID we generate for the batch
    }
  4. Update Infobip Module: Make the InfobipService available.

    typescript
    // src/infobip/infobip.module.ts
    import { Module, Global } from '@nestjs/common';
    import { InfobipService } from './infobip.service';
    // ConfigModule is already global, no need to import if AppModule imports it globally
    
    @Global() // Make InfobipService globally available if needed, or import where required
    @Module({
      providers: [InfobipService],
      exports: [InfobipService],
    })
    export class InfobipModule {}
  5. Import into AppModule: Ensure the InfobipModule is imported in AppModule.

    typescript
    // src/app.module.ts
    // ... other imports
    import { ConfigModule } from '@nestjs/config'; // Ensure ConfigModule is imported
    import { PrismaModule } from './prisma/prisma.module';
    import { InfobipModule } from './infobip/infobip.module';
    import { AppController } from './app.controller'; // Ensure AppController is imported
    import { AppService } from './app.service'; // Ensure AppService is imported
    // Import SchedulingModule later
    
    @Module({
      imports: [
        ConfigModule.forRoot({ isGlobal: true }),
        PrismaModule,
        InfobipModule,
        // Add SchedulingModule here later
      ],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}

4. Implementing Core Functionality (Scheduling Service)

This service orchestrates the process: saving schedule details to the database and triggering the Infobip API call.

  1. Generate Scheduling Module, Service, Controller:

    bash
    nest generate module scheduling
    nest generate service scheduling
    nest generate controller scheduling
  2. Create Data Transfer Objects (DTOs): Define DTOs for validating incoming API request data.

    typescript
    // src/scheduling/dto/schedule-sms.dto.ts
    import { IsNotEmpty, IsString, IsPhoneNumber, IsDateString, IsFuture } from 'class-validator';
    import { Transform } from 'class-transformer';
    import { parseISO } from 'date-fns';
    
    export class ScheduleSmsDto {
      @IsNotEmpty()
      @IsPhoneNumber(null) // Use null for region code to allow various international formats
      recipient: string;
    
      @IsNotEmpty()
      @IsString()
      message: string;
    
      @IsNotEmpty()
      @IsDateString()
      // Add custom validation if needed to ensure date is in the future
      // @IsFuture() // Requires careful date parsing/handling
      scheduledAt: string; // Accept ISO string like '2025-12-31T10:00:00Z' or with offset
    }
  3. Implement Scheduling Service: Inject PrismaService and InfobipService.

    typescript
    // src/scheduling/scheduling.service.ts
    import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
    import { PrismaService } from '../prisma/prisma.service';
    import { InfobipService } from '../infobip/infobip.service';
    import { ScheduleSmsDto } from './dto/schedule-sms.dto';
    import { Prisma, ScheduledSms, Status } from '@prisma/client';
    import { randomUUID } from 'crypto'; // Use crypto for UUIDs
    import { parseISO, isFuture } from 'date-fns';
    import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz'; // For timezone handling
    
    @Injectable()
    export class SchedulingService {
      private readonly logger = new Logger(SchedulingService.name);
    
      constructor(
        private prisma: PrismaService,
        private infobipService: InfobipService,
      ) {}
    
      async scheduleSms(data: ScheduleSmsDto): Promise<ScheduledSms> {
        const { recipient, message, scheduledAt } = data;
    
        // 1. Validate and Convert scheduledAt to UTC Date object
        let scheduledAtUtc: Date;
        try {
          // Assuming input is ISO string. Parsing handles timezone offsets.
          const parsedDate = parseISO(scheduledAt);
          if (isNaN(parsedDate.getTime())) {
             throw new Error('Invalid date format');
          }
          // Ensure the date is in the future
          if (!isFuture(parsedDate)) {
             throw new Error('Scheduled time must be in the future.');
          }
          // Store as UTC internally
          scheduledAtUtc = parsedDate; // parseISO already gives Date object (which represents UTC internally)
        } catch (e) {
          this.logger.error(`Invalid scheduledAt value: ${scheduledAt}`, e.stack);
          throw new BadRequestException(`Invalid scheduledAt value: ${e.message}`);
        }
    
    
        // 2. Generate a unique bulk ID for Infobip
        const bulkId = randomUUID(); // More robust than simple random strings
    
        // 3. Save initial record to DB (Status: PENDING) - Optional step
        // Some might prefer to only save AFTER successful Infobip scheduling.
        // Let's save first, then update status.
        const preliminaryRecord = await this.prisma.scheduledSms.create({
          data: {
            recipient,
            message,
            scheduledAt: scheduledAtUtc,
            status: Status.PENDING,
            infobipBulkId: bulkId, // Store the bulkId we generated
          },
        });
        this.logger.log(`Preliminary record created with ID: ${preliminaryRecord.id}, Bulk ID: ${bulkId}`);
    
        // 4. Call Infobip to schedule the SMS
        try {
          const infobipResponse = await this.infobipService.scheduleSms({
            recipient,
            message,
            sendAt: scheduledAtUtc, // Pass the Date object
            bulkId: bulkId,
          });
    
          // 5. Update DB record with Infobip details and status
          const updatedRecord = await this.prisma.scheduledSms.update({
            where: { id: preliminaryRecord.id },
            data: {
              infobipMessageId: infobipResponse.messageId,
              // Use a more specific status if Infobip returns PENDING/ACCEPTED
              status: Status.SCHEDULED_INFOBIP,
            },
          });
          this.logger.log(`Successfully scheduled SMS with Infobip. DB record updated: ${updatedRecord.id}`);
          return updatedRecord;
    
        } catch (error) {
          this.logger.error(`Failed to schedule with Infobip for DB record ${preliminaryRecord.id}. Updating status to FAILED.`, error.stack);
          // Update status to FAILED if Infobip call fails
          await this.prisma.scheduledSms.update({
            where: { id: preliminaryRecord.id },
            data: { status: Status.FAILED },
          });
          // Re-throw a user-friendly error
          throw new BadRequestException(`Failed to schedule SMS via provider: ${error.message}`);
        }
      }
    
      async cancelScheduledSms(id: string): Promise<ScheduledSms> {
        this.logger.log(`Attempting to cancel SMS schedule with ID: ${id}`);
        const job = await this.prisma.scheduledSms.findUnique({
          where: { id },
        });
    
        if (!job) {
          throw new NotFoundException(`Scheduled SMS with ID ${id} not found.`);
        }
    
        // Check if it's in a cancellable state (already sent/failed/canceled shouldn't be canceled again)
        if (job.status !== Status.SCHEDULED_INFOBIP && job.status !== Status.PENDING) {
           throw new BadRequestException(`Cannot cancel SMS in status: ${job.status}`);
        }
    
        // If it was successfully scheduled with Infobip, attempt to cancel there first
        if (job.status === Status.SCHEDULED_INFOBIP && job.infobipBulkId) {
          try {
            await this.infobipService.cancelScheduledSms(job.infobipBulkId);
            this.logger.log(`Successfully cancelled SMS via Infobip for bulk ID: ${job.infobipBulkId}`);
          } catch (error) {
            this.logger.error(`Failed to cancel SMS via Infobip for bulk ID: ${job.infobipBulkId}. Proceeding to mark as CANCELED in DB.`, error.stack);
            // Decide on production behavior: Log the error and mark CANCELED anyway (current)? Or use a CANCELLATION_FAILED status? Or propagate the error?
            // Current approach marks as CANCELED for simplicity but might hide underlying Infobip API issues if cancellation failed.
          }
        } else {
           this.logger.warn(`SMS ID ${id} was not in SCHEDULED_INFOBIP state or missing bulkId. Skipping Infobip cancellation call.`);
        }
    
    
        // Update status in DB to CANCELED
        const updatedJob = await this.prisma.scheduledSms.update({
          where: { id },
          data: { status: Status.CANCELED },
        });
    
        this.logger.log(`Marked SMS schedule ID ${id} as CANCELED in database.`);
        return updatedJob;
      }
    
      async getScheduledSmsById(id: string): Promise<ScheduledSms | null> {
        this.logger.log(`Fetching scheduled SMS by ID: ${id}`);
        const job = await this.prisma.scheduledSms.findUnique({
          where: { id },
        });
        if (!job) {
          throw new NotFoundException(`Scheduled SMS with ID ${id} not found.`);
        }
        return job;
      }
    
      async getAllScheduledSms(status?: Status): Promise<ScheduledSms[]> {
        this.logger.log(`Fetching all scheduled SMS with status filter: ${status || 'None'}`);
        const whereClause: Prisma.ScheduledSmsWhereInput = {};
        if (status) {
          whereClause.status = status;
        }
        return this.prisma.scheduledSms.findMany({
          where: whereClause,
          orderBy: { scheduledAt: 'asc' },
        });
      }
    }
  4. Update Scheduling Module: Import dependencies.

    typescript
    // src/scheduling/scheduling.module.ts
    import { Module } from '@nestjs/common';
    import { SchedulingService } from './scheduling.service';
    import { SchedulingController } from './scheduling.controller';
    // PrismaModule and InfobipModule are global, no need to import directly here
    
    @Module({
      controllers: [SchedulingController],
      providers: [SchedulingService],
    })
    export class SchedulingModule {}
  5. Import into AppModule:

    typescript
    // src/app.module.ts
    // ... other imports
    import { ConfigModule } from '@nestjs/config'; // Ensure ConfigModule is imported
    import { PrismaModule } from './prisma/prisma.module';
    import { InfobipModule } from './infobip/infobip.module';
    import { SchedulingModule } from './scheduling/scheduling.module';
    import { AppController } from './app.controller'; // Ensure AppController is imported
    import { AppService } from './app.service'; // Ensure AppService is imported
    
    @Module({
      imports: [
        ConfigModule.forRoot({ isGlobal: true }),
        PrismaModule,
        InfobipModule,
        SchedulingModule, // Add the scheduling module
      ],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}

5. Building the API Layer (Controller)

Implement the controller to expose endpoints for interacting with the SchedulingService.

  1. Implement Scheduling Controller:

    typescript
    // src/scheduling/scheduling.controller.ts
    import { Controller, Post, Body, Get, Param, Delete, Query, ParseEnumPipe, HttpCode, HttpStatus, ValidationPipe, UsePipes } from '@nestjs/common';
    import { SchedulingService } from './scheduling.service';
    import { ScheduleSmsDto } from './dto/schedule-sms.dto';
    import { Status } from '@prisma/client'; // Import Status enum
    
    @Controller('schedule')
    export class SchedulingController {
      constructor(private readonly schedulingService: SchedulingService) {}
    
      @Post()
      @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) // Apply validation pipe per endpoint or globally
      @HttpCode(HttpStatus.CREATED)
      async scheduleSms(@Body() scheduleSmsDto: ScheduleSmsDto) {
        // DTO validation happens automatically due to ValidationPipe
        return this.schedulingService.scheduleSms(scheduleSmsDto);
      }
    
      @Get()
      async getAllScheduledSms(
        @Query('status', new ParseEnumPipe(Status, { optional: true })) status?: Status,
      ) {
        // ParseEnumPipe validates the status query parameter against the Status enum
        return this.schedulingService.getAllScheduledSms(status);
      }
    
      @Get(':id')
      async getScheduledSmsById(@Param('id') id: string) {
        return this.schedulingService.getScheduledSmsById(id);
      }
    
      @Delete(':id')
      @HttpCode(HttpStatus.OK) // Return 200 OK on successful cancellation
      async cancelScheduledSms(@Param('id') id: string) {
        return this.schedulingService.cancelScheduledSms(id);
      }
    }
  2. Enable Global Validation Pipe: It's often better to enable validation globally. Ensure this line is present and uncommented in src/main.ts:

    typescript
    // src/main.ts
    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    import { ValidationPipe } from '@nestjs/common';
    import { PrismaService } from './prisma/prisma.service'; // Ensure PrismaService is imported if not already
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
    
      // Enable ValidationPipe globally
      app.useGlobalPipes(new ValidationPipe({
        whitelist: true, // Strip properties not in DTO
        transform: true, // Automatically transform payloads to DTO instances
        forbidNonWhitelisted: true, // Throw error if non-whitelisted properties are present
        transformOptions: {
          enableImplicitConversion: true, // Allow basic type conversions (e.g., string query param to number if typed in DTO)
        },
      }));
    
      // Enable Prisma shutdown hooks
      const prismaService = app.get(PrismaService);
      await prismaService.enableShutdownHooks(app);
    
      await app.listen(3000);
      console.log(`Application is running on: ${await app.getUrl()}`);
    }
    bootstrap();

6. API Endpoint Testing

You can now test the API endpoints using curl or a tool like Postman. Ensure your NestJS application is running (npm run start:dev).

  • Schedule an SMS: Replace YOUR_PHONE_NUMBER (e.g., 447123456789) and adjust the scheduledAt time (must be in the future, ISO 8601 format).

    bash
    curl -X POST http://localhost:3000/schedule \
    -H ""Content-Type: application/json"" \
    -d '{
      ""recipient"": ""YOUR_PHONE_NUMBER"",
      ""message"": ""Hello from NestJS Scheduler! This is a test."",
      ""scheduledAt"": ""2025-12-31T10:00:00Z""
    }'

    Expected Response (Example - IDs will vary):

    json
    {
        ""id"": ""clxhw9q5e0000q5zwabcd1234"",
        ""recipient"": ""YOUR_PHONE_NUMBER"",
        ""message"": ""Hello from NestJS Scheduler! This is a test."",
        ""scheduledAt"": ""2025-12-31T10:00:00.000Z"",
        ""status"": ""SCHEDULED_INFOBIP"",
        ""infobipBulkId"": ""some-uuid-generated-earlier"",
        ""infobipMessageId"": ""infobip-message-id-returned"",
        ""createdAt"": ""2024-01-01T12:00:00.000Z"",
        ""updatedAt"": ""2024-01-01T12:00:05.000Z""
    }

Frequently Asked Questions

How to schedule SMS messages with NestJS?

You can schedule SMS messages by sending a POST request to the /schedule endpoint with the recipient's phone number, message content, and desired send time (scheduledAt) in ISO 8601 format. The application uses NestJS to handle the request, validate the data, and interact with the Infobip API to schedule the message. The scheduled message details are stored in a PostgreSQL database using Prisma.

What is the Infobip SMS API used for?

The Infobip SMS API is used to send and schedule SMS messages. The NestJS application integrates with this API to handle the actual sending and scheduling of messages after they are stored in the database. The Infobip Node.js SDK simplifies the integration process.

Why use NestJS for SMS scheduling?

NestJS provides a robust and structured framework for building server-side applications. Its modular architecture, dependency injection, and built-in features like validation and configuration make it ideal for building reliable and scalable SMS scheduling systems. NestJS also works well with TypeScript, enhancing type safety and code maintainability.

When should I use an SMS scheduler?

An SMS scheduler is beneficial for automated reminders, notifications, and time-sensitive alerts. Use cases include appointment reminders, marketing campaigns, two-factor authentication, and any scenario requiring automated SMS messages at specific times.

Can I cancel a scheduled SMS message?

Yes, you can cancel a scheduled SMS message by sending a DELETE request to the /schedule/{id} endpoint, where {id} is the unique identifier of the scheduled message. The application will attempt to cancel the message with Infobip and update the status in the database.

What database is used for storing scheduled SMS messages?

The application uses PostgreSQL as the database to store scheduled SMS message information. Prisma, a next-generation ORM, simplifies database interactions and provides type safety.

How to set up Infobip API credentials?

Obtain your Infobip API Key and Base URL from your Infobip account dashboard. These credentials should be stored securely in a .env file as INFOBIP_API_KEY and INFOBIP_BASE_URL. The NestJS ConfigModule loads these environment variables.

What is Prisma used for in this project?

Prisma is an Object-Relational Mapper (ORM) that simplifies database operations. It allows you to define your data models in a schema file (schema.prisma), generate migrations, and interact with the database using type-safe methods.

How to set up the NestJS project for SMS scheduling?

Create a new NestJS project using the NestJS CLI, install the required dependencies (@nestjs/config, @infobip-api/sdk, prisma, @prisma/client, etc.), configure the environment variables (database URL, Infobip credentials), and set up the Prisma schema.

What are the prerequisites for building this SMS scheduler?

You need Node.js, npm or yarn, an Infobip account, access to a PostgreSQL database, basic familiarity with TypeScript, REST APIs, and asynchronous programming. Docker and Docker Compose are optional but recommended.

How to manage different time zones for SMS scheduling?

The provided code uses `date-fns-tz` library to handle timezone conversions. Although the application stores all dates internally in UTC, it accepts input dates with timezone offsets and parses them correctly. For the Infobip API call, always send the `scheduledAt` parameter as a UTC Date object, which ensures timezone-independent scheduling.

How does the SMS scheduler handle cancellation failures with Infobip?

If cancellation with Infobip fails, the application will log the error and still mark the message as CANCELED in the database. This simplifies error handling but might mask potential issues with the Infobip API. For production, consider more advanced error handling strategies.

How to retrieve scheduled SMS messages?

Use the GET /schedule endpoint to retrieve all scheduled messages or /schedule/{id} to get a specific message by ID. You can optionally filter by status using the `status` query parameter (e.g., /schedule?status=PENDING).

What is the purpose of the bulkId?

The bulkId is a unique identifier generated for each batch of scheduled SMS messages. It's used to reference the scheduled messages within Infobip, particularly for cancellation or status updates. This ensures that operations target the correct message batch.

What are the different status values for scheduled messages?

Scheduled messages can have the following statuses: PENDING (scheduled in our DB but not yet sent to Infobip), SCHEDULED_INFOBIP (successfully scheduled with Infobip), SENT (confirmed sent by Infobip - requires webhooks), FAILED (failed to schedule or send), and CANCELED (canceled by user request).