code examples

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

Build a NestJS SMS Scheduling System with Sinch

A guide on creating an SMS scheduling and reminder application using NestJS, Sinch SMS API, PostgreSQL, and Prisma.

This guide provides a complete walkthrough for building a robust SMS scheduling and reminder application using the NestJS framework and the Sinch SMS API. We'll cover everything from initial project setup to deployment and monitoring, enabling you to reliably send timely SMS notifications based on scheduled events.

This application solves the common need for automated reminders – think appointment confirmations, subscription renewals, task deadlines, or event notifications. By leveraging NestJS's structure and Sinch's reliable SMS delivery and scheduling features, we create a scalable and maintainable solution. We will use PostgreSQL with Prisma for persistence.

Project Overview and Goals

What We're Building:

A NestJS backend application that exposes an API to:

  1. Schedule an SMS reminder for a future date and time.
  2. Store appointment/reminder details persistently.
  3. Automatically send the SMS via Sinch at the specified time.
  4. (Optional) Allow cancelling scheduled reminders.

Technologies Used:

  • NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Chosen for its modular architecture, built-in features (like validation pipes), and TypeScript support.
  • Sinch SMS API: A powerful API for sending and receiving SMS messages globally. Chosen for its direct support for scheduled sends (send_at parameter), eliminating the need for complex local scheduling logic for the actual send time. We'll use the @sinch/sdk-core.
  • PostgreSQL: A robust open-source relational database. Chosen for its reliability and data integrity features.
  • Prisma: A next-generation ORM for Node.js and TypeScript. Chosen for its type safety, auto-generated migrations, and intuitive API.
  • Luxon: A library for handling dates and times. Chosen for its immutability and clear API for time zone management.
  • Docker & Docker Compose: For containerizing the application and database for consistent development and deployment environments.

System Architecture:

mermaid
graph LR
    Client[Client Application / API Consumer] -->|1. POST /appointments| API(NestJS API);
    API -->|2. Validate & Save| DB[(PostgreSQL w/ Prisma)];
    API -->|3. Schedule Send| Sinch(Sinch SMS API);
    Sinch -->|4. Send SMS at 'send_at' time| UserDevice(User's Mobile Device);
    subgraph NestJS Application
        API
        AppointmentsService(Appointments Service)
        SinchService(Sinch Service)
        DB
    end

    %% Interactions
    Client --Makes API Request--> API;
    API --Persists Data--> DB;
    API --Calls SDK Method--> SinchService;
    SinchService --Uses 'send_at' Parameter--> Sinch;
  1. A client sends a POST request to the NestJS API endpoint (/appointments) with reminder details (recipient number, message content, time).
  2. The NestJS API validates the request, saves the appointment details to the PostgreSQL database via Prisma.
  3. The NestJS API, through a dedicated SinchService, calls the Sinch SMS API using the @sinch/sdk-core, providing the recipient, message, and the crucial send_at parameter set to the desired delivery time (converted to UTC).
  4. Sinch internally handles the scheduling and sends the SMS message at the specified send_at time to the user's device.

Prerequisites:

  • Node.js (v18 or later recommended) and pnpm (or npm/yarn, adjust commands accordingly).
  • Docker and Docker Compose installed.
  • A Sinch account with API credentials (Project ID, Key ID, Key Secret) and a configured Sender Number (or Alphanumeric Sender ID).
  • Basic understanding of TypeScript, NestJS concepts, and REST APIs.
  • A code editor (like VS Code).
  • A tool for making API requests (like Postman or curl).

1. Setting up the Project

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

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

    bash
    npx @nestjs/cli new sinch-sms-scheduler
    cd sinch-sms-scheduler

    Choose pnpm when prompted by the NestJS CLI for the package manager, or adjust the commands below if you prefer npm or yarn.

  2. Install Dependencies:

    bash
    # Sinch SDK, Date/Time handling, HTTP client (often needed)
    pnpm add @sinch/sdk-core luxon @nestjs/axios axios
    
    # Validation & Configuration
    pnpm add class-validator class-transformer @nestjs/config dotenv
    
    # Scheduling (Built-in) - No explicit install needed
    
    # Prisma (ORM & Client)
    pnpm add @prisma/client
    pnpm add -D prisma # Install Prisma CLI as a dev dependency
    
    # PostgreSQL driver
    pnpm add pg
  3. Initialize Prisma: Set up Prisma with PostgreSQL:

    bash
    pnpm prisma init --datasource-provider postgresql

    This creates a prisma directory with a schema.prisma file and a .env file for database credentials.

  4. Configure Environment Variables: Update the .env file created by Prisma. Add your Sinch credentials and other configurations:

    dotenv
    # .env
    
    # Database (Prisma)
    # Replace with your preferred DB connection string format if needed
    # Example for local Docker setup (see Docker Compose section)
    DATABASE_URL=""postgresql://user:password@localhost:5432/sms_scheduler?schema=public""
    
    # Sinch Credentials - Replace with your actual values
    SINCH_PROJECT_ID=""YOUR_SINCH_PROJECT_ID""
    SINCH_KEY_ID=""YOUR_SINCH_KEY_ID""
    SINCH_KEY_SECRET=""YOUR_SINCH_KEY_SECRET""
    SINCH_FROM_NUMBER=""+1xxxxxxxxxx"" # Your Sinch registered sender number in E.164 format
    SINCH_SMS_REGION=""us"" # Or ""eu"", ""au"", ""br"", ""ca"" depending on your account region
    
    # Application Port
    PORT=3000
    • DATABASE_URL: Connection string for your PostgreSQL database. We'll set up a local one using Docker later.
    • SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET: Replace these placeholders. Obtain these from your Sinch Dashboard under API Credentials. Navigate to your Project > Settings > API Credentials.
    • SINCH_FROM_NUMBER: Replace this placeholder. The phone number or Alphanumeric Sender ID registered with Sinch that messages will be sent from. Must be in E.164 format (e.g., +12125551234). Get this from your Sinch Dashboard > Numbers.
    • SINCH_SMS_REGION: The region your Sinch account operates in (e.g., us, eu). This ensures the SDK connects to the correct regional endpoint. Check your Sinch Dashboard or documentation if unsure.
    • PORT: The port your NestJS application will run on.
  5. Integrate Configuration Module: Load the environment variables into your NestJS application using @nestjs/config. Update src/app.module.ts:

    typescript
    // src/app.module.ts
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config'; // Import ConfigModule
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    // We will add other modules here later (Database, Appointments, Sinch)
    
    @Module({
      imports: [
        ConfigModule.forRoot({ // Load .env variables globally
          isGlobal: true,
          envFilePath: '.env',
        }),
        // ... other modules will be added here
      ],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}

    ConfigModule.forRoot({ isGlobal: true }) makes environment variables accessible throughout the application via ConfigService.

  6. Set up Docker Compose for Database: Create a docker-compose.yml file in the project root for easy local database setup:

    yaml
    # docker-compose.yml
    version: '3.8'
    services:
      postgres:
        image: postgres:15
        container_name: sinch-sms-db
        environment:
          POSTGRES_USER: user        # Match .env user
          POSTGRES_PASSWORD: password  # Match .env password
          POSTGRES_DB: sms_scheduler # Match .env database name
        ports:
          - ""5432:5432"" # Expose port 5432 locally
        volumes:
          - postgres_data:/var/lib/postgresql/data
        restart: always
    
    volumes:
      postgres_data:
        driver: local

    Start the database container:

    bash
    docker-compose up -d
  7. Project Structure Overview: Your initial structure will look like this:

    sinch-sms-scheduler/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ └── main.ts ├── test/ ├── .env ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── docker-compose.yml ├── nest-cli.json ├── package.json ├── pnpm-lock.yaml ├── README.md ├── tsconfig.build.json └── tsconfig.json

    We will create new modules (database, sinch, appointments) within src as we build the features.

2. Implementing Core Functionality (Sinch Service)

Let's create a dedicated service to handle interactions with the Sinch SMS API.

  1. Generate Sinch Module and Service:

    bash
    nest generate module sinch
    nest generate service sinch --no-spec # --no-spec skips test file generation for now

    This creates src/sinch/sinch.module.ts and src/sinch/sinch.service.ts.

  2. Implement Sinch Service: Populate src/sinch/sinch.service.ts with the logic to initialize the Sinch client and send scheduled messages.

    typescript
    // src/sinch/sinch.service.ts
    import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import { SinchClient } from '@sinch/sdk-core';
    import { DateTime } from 'luxon'; // Use Luxon for date/time
    
    @Injectable()
    export class SinchService implements OnModuleInit {
      private readonly logger = new Logger(SinchService.name);
      private sinchClient: SinchClient;
    
      constructor(private configService: ConfigService) {}
    
      onModuleInit() {
        // Initialize Sinch Client when the module loads
        const projectId = this.configService.get<string>('SINCH_PROJECT_ID');
        const keyId = this.configService.get<string>('SINCH_KEY_ID');
        const keySecret = this.configService.get<string>('SINCH_KEY_SECRET');
        const region = this.configService.get<string>('SINCH_SMS_REGION');
    
        if (!projectId || !keyId || !keySecret || !region || projectId === 'YOUR_SINCH_PROJECT_ID') {
          this.logger.error('Missing or placeholder Sinch API credentials in environment variables. Please update .env');
          throw new Error('Sinch API credentials not configured or are placeholders.');
        }
    
        this.sinchClient = new SinchClient({ projectId, keyId, keySecret, region });
        this.logger.log('Sinch Client Initialized');
      }
    
      /**
       * Schedules an SMS message using the Sinch API's send_at feature.
       * @param to Recipient phone number in E.164 format (e.g., +1xxxxxxxxxx)
       * @param message The SMS message body
       * @param sendAt The Date object representing when the message should be sent
       * @returns The batch ID from the Sinch API response
       * @throws Error if the Sinch API call fails
       */
      async scheduleSms(to: string, message: string, sendAt: Date): Promise<string> {
        const fromNumber = this.configService.get<string>('SINCH_FROM_NUMBER');
    
        if (!fromNumber || fromNumber === '+1xxxxxxxxxx') {
          this.logger.error('SINCH_FROM_NUMBER not configured or is a placeholder.');
          throw new Error('Sender number not configured or is a placeholder.');
        }
    
        // --- CRITICAL: Convert sendAt Date to UTC ISO string for Sinch API ---
        // Luxon helps manage timezones correctly. Ensure the input 'sendAt'
        // represents the *intended local time* for the reminder.
        // We convert it to UTC because Sinch 'send_at' expects UTC.
        const sendAtUtcIso = DateTime.fromJSDate(sendAt).toUTC().toISO();
        // Example: If sendAt is 2025-04-20 14:00:00 in America/New_York (EDT, UTC-4),
        // this converts it to ""2025-04-20T18:00:00.000Z""
    
        this.logger.log(`Scheduling SMS to ${to} from ${fromNumber} at ${sendAtUtcIso}`);
    
        try {
          const response = await this.sinchClient.sms.batches.send({
            sendSMSRequestBody: {
              to: [to], // Must be an array
              from: fromNumber,
              body: message,
              send_at: sendAtUtcIso, // Use the UTC ISO string
              // Optional: Add delivery_report: 'full' or 'summary' if needed
            },
          });
    
          this.logger.log(`SMS scheduled successfully. Batch ID: ${response.id}`);
          return response.id; // Return the batch ID for tracking
        } catch (error) {
          this.logger.error(`Failed to schedule SMS via Sinch: ${error.message}`, error.stack);
          // Consider re-throwing a custom error or handling specific Sinch API errors
          throw new Error(`Sinch API Error: ${error.message}`);
        }
      }
    
      // Add other methods if needed, e.g., cancelScheduledSms(batchId)
      // Note: Sinch API might require specific endpoints/methods for cancellation.
      // Check Sinch documentation for batch cancellation capabilities.
    }
    • OnModuleInit: Initializes the SinchClient once when the module is loaded, ensuring credentials are read correctly and are not placeholders.
    • scheduleSms:
      • Takes the recipient (to), message (body), and the desired send time (sendAt as a JavaScript Date object).
      • Retrieves configuration using ConfigService, checking for placeholder values.
      • Crucially, uses Luxon to convert the sendAt Date object into a UTC ISO 8601 string (YYYY-MM-DDTHH:mm:ss.sssZ), which is the format required by the Sinch send_at parameter. This handles time zone conversions correctly.
      • Calls sinchClient.sms.batches.send with the required parameters.
      • Logs success or failure and returns the batchId from Sinch, which can be used for tracking or potential cancellation.
    • Error Handling: Basic logging is included. Production systems should have more robust error handling (see Section 5).
  3. Export and Register Service: Make sure SinchService is exported from its module and the SinchModule is imported into the main AppModule.

    typescript
    // src/sinch/sinch.module.ts
    import { Module } from '@nestjs/common';
    import { SinchService } from './sinch.service';
    // ConfigModule is already global, no need to import here unless specific config scoping is needed
    
    @Module({
      providers: [SinchService],
      exports: [SinchService], // Export the service for other modules to use
    })
    export class SinchModule {}
    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 { SinchModule } from './sinch/sinch.module'; // Import SinchModule
    import { DatabaseModule } from './database/database.module'; // Will add later
    import { AppointmentsModule } from './appointments/appointments.module'; // Will add later
    
    @Module({
      imports: [
        ConfigModule.forRoot({
          isGlobal: true,
          envFilePath: '.env',
        }),
        SinchModule, // Register SinchModule
        // DatabaseModule, // Register later
        // AppointmentsModule, // Register later
      ],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}

3. Building the API Layer (Appointments)

Now, let's create the API endpoints to receive scheduling requests.

  1. Generate Appointments Module, Controller, Service:

    bash
    nest generate module appointments
    nest generate controller appointments --no-spec
    nest generate service appointments --no-spec
  2. Define Data Transfer Object (DTO): Create a DTO to define the expected request body shape and apply validation rules.

    typescript
    // src/appointments/dto/create-appointment.dto.ts
    import {
      IsString,
      IsNotEmpty,
      IsPhoneNumber,
      IsDateString,
      MinDate,
      IsOptional,
      IsDate, // Add IsDate for validation after transformation
    } from 'class-validator';
    import { Transform } from 'class-transformer';
    
    export class CreateAppointmentDto {
      @IsString()
      @IsNotEmpty()
      patientName: string;
    
      @IsString()
      @IsOptional() // Example: Doctor name might be optional
      doctorName?: string;
    
      @IsPhoneNumber(null, { message: 'Phone number must be a valid E.164 format (e.g., +12125551234)' }) // Validate E.164 format
      @IsNotEmpty()
      phoneNumber: string;
    
      @IsDateString({ strict: true }, { message: 'Appointment time must be a valid ISO 8601 date string (e.g., 2025-12-31T14:30:00.000Z)' })
      @IsNotEmpty()
      @Transform(({ value }) => new Date(value), { toClassOnly: true }) // Transform string to Date object *before* validation
      @IsDate() // Validate that the transformation resulted in a Date object
      @MinDate(new Date(), { message: 'Appointment time must not be in the past' }) // Use standard MinDate
      appointmentTime: Date; // Will be received as ISO string, transformed to Date
    
      @IsString()
      @IsNotEmpty()
      message: string; // The custom message to send
    }
    • Uses class-validator decorators for robust validation (required fields, phone number format, date format, non-past date).
    • @IsPhoneNumber(null) validates against E.164 format by default.
    • @IsDateString ensures the input is a valid ISO 8601 date string.
    • @Transform(({ value }) => new Date(value), { toClassOnly: true }) converts the valid date string into a JavaScript Date object before other date validations run.
    • @IsDate() ensures the transformation was successful.
    • @MinDate(new Date()) ensures the date is now or in the future (not strictly future, but not past).
  3. Implement Appointments Controller: Define the API endpoint in src/appointments/appointments.controller.ts.

    typescript
    // src/appointments/appointments.controller.ts
    import { Controller, Post, Body, Logger, HttpException, HttpStatus, ParseIntPipe, Get, Param } from '@nestjs/common';
    import { AppointmentsService } from './appointments.service';
    import { CreateAppointmentDto } from './dto/create-appointment.dto';
    import { Appointment } from '@prisma/client'; // Import Prisma model later
    
    @Controller('appointments')
    export class AppointmentsController {
      private readonly logger = new Logger(AppointmentsController.name);
    
      constructor(private readonly appointmentsService: AppointmentsService) {}
    
      @Post()
      // @UsePipes(new ValidationPipe(...)) // No longer needed if using global pipe in main.ts
      async scheduleAppointment(
        @Body() createAppointmentDto: CreateAppointmentDto, // DTO is validated by global pipe
      ): Promise<{ message: string; appointment: Appointment }> { // Return the created Appointment
        this.logger.log(`Received request to schedule appointment: ${JSON.stringify(createAppointmentDto)}`);
        try {
          // The DTO is already validated and transformed by the global ValidationPipe
          const newAppointment = await this.appointmentsService.scheduleAppointment(createAppointmentDto);
          this.logger.log(`Appointment scheduled successfully with ID: ${newAppointment.id}`);
          return {
            message: 'Appointment scheduled successfully and SMS reminder queued.',
            appointment: newAppointment, // Return the full appointment record
          };
        } catch (error) {
          this.logger.error(`Failed to schedule appointment: ${error.message}`, error.stack);
          // Re-throw HTTP exceptions directly, wrap others
          if (error instanceof HttpException) {
             throw error;
          }
          throw new HttpException('Failed to schedule appointment.', HttpStatus.INTERNAL_SERVER_ERROR);
        }
      }
    
       @Get(':id')
       async getAppointment(@Param('id', ParseIntPipe) id: number): Promise<Appointment> {
         const appointment = await this.appointmentsService.getAppointmentById(id);
         if (!appointment) {
           throw new HttpException('Appointment not found', HttpStatus.NOT_FOUND);
         }
         return appointment;
       }
    
      // Add other endpoints later if needed (DELETE /:id, etc.)
    }
    • @Controller('appointments'): Defines the base route /appointments.
    • @Post(): Handles POST requests to /appointments. Assumes global ValidationPipe is enabled in main.ts.
    • Injects AppointmentsService to handle the business logic.
    • Returns a success message and the created Appointment object from the database.
    • Includes error handling, re-throwing known HttpExceptions (like BadRequestException from the service) and wrapping others in a generic 500 Internal Server Error.
    • Added a simple GET /appointments/:id endpoint example.
  4. Implement Appointments Service (Initial): Create the business logic in src/appointments/appointments.service.ts. This service will orchestrate saving to the database (later) and calling the SinchService.

    typescript
    // src/appointments/appointments.service.ts
    import { Injectable, Logger, BadRequestException } from '@nestjs/common'; // Import BadRequestException
    import { CreateAppointmentDto } from './dto/create-appointment.dto';
    import { SinchService } from '../sinch/sinch.service';
    // import { DatabaseService } from '../database/database.service'; // Import later
    import { Appointment } from '@prisma/client'; // Import later
    import { Duration, DateTime } from 'luxon';
    
    @Injectable()
    export class AppointmentsService {
      private readonly logger = new Logger(AppointmentsService.name);
    
      constructor(
        private readonly sinchService: SinchService,
        // private readonly databaseService: DatabaseService, // Inject later
      ) {}
    
      async scheduleAppointment(dto: CreateAppointmentDto): Promise<any> { // Update return type later to Promise<Appointment>
        this.logger.log(`Processing appointment scheduling for ${dto.phoneNumber}`);
    
        // --- Business Logic Validation (Example: Ensure time is reasonably far in the future) ---
        // Although DTO validates >= now, business logic might require ""at least 5 mins from now""
        const minLeadTime = Duration.fromObject({ minutes: 5 });
        if (DateTime.fromJSDate(dto.appointmentTime) < DateTime.now().plus(minLeadTime)) {
           this.logger.warn(`Appointment time ${dto.appointmentTime.toISOString()} is too soon (less than 5 minutes away).`);
           // Throw BadRequestException for client-fixable errors
           throw new BadRequestException('Appointment time must be at least 5 minutes in the future.');
        }
    
    
        // --- TODO: Step 1: Save appointment details to the database ---
        // We will add this in the Database section (Section 6)
        // const savedAppointment = await this.databaseService.createAppointment(dto);
        // For now, let's simulate a saved object:
        const simulatedSavedAppointment = {
          id: Math.floor(Math.random() * 1000), // Temporary ID
          ...dto,
          status: 'PENDING_SAVE', // Temporary status
          createdAt: new Date(),
          updatedAt: new Date(),
          sinchBatchId: null,
        };
        this.logger.log(`Simulated save for appointment ID: ${simulatedSavedAppointment.id}`);
    
    
        // --- Step 2: Schedule the SMS via Sinch ---
        try {
          const batchId = await this.sinchService.scheduleSms(
            dto.phoneNumber,
            dto.message, // Use the message from the DTO
            dto.appointmentTime, // Pass the Date object (already transformed)
          );
    
          // --- TODO: Step 3: Update the appointment record with the Sinch Batch ID and status ---
          // We will update the actual DB record later
          // await this.databaseService.updateAppointmentStatus(savedAppointment.id, 'SCHEDULED', batchId);
          simulatedSavedAppointment.sinchBatchId = batchId;
          simulatedSavedAppointment.status = 'SCHEDULED';
          this.logger.log(`Updated simulated appointment ${simulatedSavedAppointment.id} with Batch ID ${batchId}`);
    
          return simulatedSavedAppointment; // Return the (simulated) saved appointment data
    
        } catch (error) {
          this.logger.error(`Failed to schedule SMS via Sinch for appointment ${simulatedSavedAppointment.id}: ${error.message}`);
          // --- TODO: Step 4: Handle failure - Update appointment status in DB to 'FAILED' ---
          // await this.databaseService.updateAppointmentStatus(savedAppointment.id, 'FAILED');
          simulatedSavedAppointment.status = 'FAILED';
          // Re-throw the error to be caught by the controller
          throw error; // Controller will wrap this in HttpException if it's not already one
        }
      }
    
      // Add methods for getAppointment, cancelAppointment later
      async getAppointmentById(id: number): Promise<any | null> {
         this.logger.log(`Fetching appointment with ID: ${id} (Simulation)`);
         // Simulate finding an appointment - replace with DB call later
         if (id < 1000) { // Simulate finding some IDs
             return { id: id, status: 'SCHEDULED', /* other fields */ };
         }
         return null;
      }
    }
    • Injects SinchService.
    • Includes placeholder comments for database interactions.
    • Uses BadRequestException for the 5-minute lead time validation, allowing the client to potentially fix the request.
    • Calls sinchService.scheduleSms with the details from the validated and transformed DTO.
    • Handles potential errors from the SinchService call.
  5. Register Modules: Ensure the AppointmentsModule is registered in src/app.module.ts and that it imports SinchModule.

    typescript
    // src/appointments/appointments.module.ts
    import { Module } from '@nestjs/common';
    import { AppointmentsService } from './appointments.service';
    import { AppointmentsController } from './appointments.controller';
    import { SinchModule } from '../sinch/sinch.module'; // Import SinchModule
    // import { DatabaseModule } from '../database/database.module'; // Import later
    
    @Module({
      imports: [
        SinchModule, // Make SinchService available for injection
        // DatabaseModule, // Import later
      ],
      controllers: [AppointmentsController],
      providers: [AppointmentsService],
    })
    export class AppointmentsModule {}
    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 { SinchModule } from './sinch/sinch.module';
    import { AppointmentsModule } from './appointments/appointments.module'; // Import AppointmentsModule
    import { DatabaseModule } from './database/database.module'; // Import later
    
    @Module({
      imports: [
        ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }),
        SinchModule,
        AppointmentsModule, // Register AppointmentsModule
        // DatabaseModule, // Register later
      ],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}
  6. Enable Validation Globally: It's cleaner to enable the ValidationPipe globally in src/main.ts.

    typescript
    // src/main.ts
    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
    
      app.useGlobalPipes(
        new ValidationPipe({
          transform: true, // Automatically transform payloads to DTO instances
          whitelist: true, // Strip properties not defined in DTO
          forbidNonWhitelisted: true, // Throw error if extra properties are sent
          transformOptions: {
            enableImplicitConversion: false, // Be explicit about conversions using @Transform
          },
          // Ensure validation errors aren't too verbose in production
          // disableErrorMessages: process.env.NODE_ENV === 'production',
        }),
      );
    
      // Optional: Add global prefix
      // app.setGlobalPrefix('api/v1');
    
      await app.listen(port);
      logger.log(`Application listening on port ${port}`);
      logger.log(`API endpoint available at http://localhost:${port}`); // Adjust if using global prefix
    }
    bootstrap();

    The @UsePipes(...) decorator can now be removed from the controller method.

4. Integrating with Sinch (Covered in Section 2)

Section 2 detailed the creation of SinchService which encapsulates all interaction with the Sinch SDK. Key points for integration:

  • Credentials: Securely loaded from .env via ConfigService. Ensure placeholders are replaced with real values.
  • Initialization: SinchClient is initialized in onModuleInit.
  • Scheduling: The scheduleSms method handles the API call, including the critical send_at parameter formatted as a UTC ISO string.
  • Error Handling: Basic error logging is in place.
  • Configuration: SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET, SINCH_FROM_NUMBER, SINCH_SMS_REGION environment variables are essential. Double-check these values in your .env file and ensure they match your Sinch dashboard details precisely.

Obtaining Credentials:

  1. Log in to your Sinch account (https://dashboard.sinch.com/).
  2. Navigate to the relevant Project (or create one).
  3. Go to Settings -> API Credentials.
  4. Copy the Project ID, Key ID, and Key Secret. Treat the Key Secret like a password – do not commit it to version control. Place these in your .env file.
  5. Go to Numbers -> Your Numbers.
  6. Copy the desired Sender Number in E.164 format (e.g., +1xxxxxxxxxx). Ensure this number is SMS enabled. Place this in SINCH_FROM_NUMBER in your .env file.
  7. Note your Account Region (usually visible in the dashboard URL or account settings) for the SINCH_SMS_REGION variable in .env.

5. Implementing Error Handling, Logging, and Retry Mechanisms

Production systems require more robust error handling.

  1. Consistent Error Strategy (Exception Filters): NestJS uses Exception Filters. The global ValidationPipe handles DTO errors (400). The controller catches service errors, re-throws HttpExceptions (like BadRequestException), and wraps others as 500 errors. This provides a good baseline.

    Refined Service Error Handling Example:

    typescript
    // src/sinch/sinch.service.ts
    // ... inside scheduleSms try-catch block ...
     catch (error) {
        this.logger.error(`Failed to schedule SMS via Sinch: ${error.message}`, error.stack);
        // You could check for specific Sinch error types/codes if the SDK provides them
        // and throw more specific custom exceptions if needed.
        // Example: if (error?.response?.data?.code === 'SOME_SINCH_CODE') { ... }
        throw new Error(`Sinch API Error during scheduling: ${error.message}`); // Re-throw generic error
     }
    
    // src/appointments/appointments.service.ts
    // ... inside scheduleAppointment try-catch block for Sinch call ...
    // (Error handling already present, re-throws the error from SinchService)

Frequently Asked Questions

How to schedule SMS reminders with NestJS?

Use NestJS with the Sinch SMS API to build an SMS scheduling application. Create a NestJS backend that interacts with the Sinch API to send SMS messages at specified times, storing reminder details in a database like PostgreSQL.

What is the Sinch SMS API used for in NestJS?

The Sinch SMS API enables sending and receiving SMS messages globally within a NestJS application. Its `send_at` parameter allows direct scheduling of messages, simplifying the application logic.

Why use Prisma with PostgreSQL in this project?

Prisma, an ORM for Node.js and TypeScript, enhances type safety and simplifies database interactions. It's used with PostgreSQL for reliable data persistence and integrity in the SMS scheduling application.

When should I convert sendAt time to UTC for Sinch?

Always convert the desired send time (`sendAt`) to UTC using a library like Luxon before sending it to the Sinch API. Sinch's `send_at` parameter expects a UTC ISO 8601 string for accurate scheduling.

Can I cancel scheduled reminders with the Sinch API?

The Sinch API has batch cancellation capabilities, so cancelling scheduled SMS messages may be possible. Check Sinch's documentation for how to use the necessary endpoints and methods for cancellation.

How to set up a NestJS project for SMS scheduling?

Use the NestJS CLI to create a new project, then install dependencies like `@sinch/sdk-core`, `luxon`, `@nestjs/axios`, and Prisma. Configure environment variables for Sinch and database credentials.

What is Luxon used for in SMS scheduling?

Luxon is a date and time handling library used for its immutable nature and clear API for time zone management. This ensures accurate conversion of scheduled times to UTC for the Sinch API.

How to handle Sinch API credentials securely in NestJS?

Store Sinch API credentials (Project ID, Key ID, Key Secret) in a `.env` file. Load these environment variables into your NestJS application using the `@nestjs/config` module, ensuring they are not committed to version control.

What is the purpose of the SinchService in this NestJS project?

SinchService encapsulates interactions with the Sinch SMS API, initializing the Sinch client and providing methods to schedule SMS messages using credentials and the send_at parameter.

How to validate appointment scheduling requests in NestJS?

Use class-validator and class-transformer to define a Data Transfer Object (DTO) with validation rules. Enable a global ValidationPipe in `main.ts` to automatically validate incoming requests against the DTO schema.

What database is used for storing appointment details?

PostgreSQL is used for persistent storage of appointment details like patient name, phone number, appointment time, and the SMS message content.

How to handle errors when scheduling SMS messages with Sinch?

Implement error handling within the SinchService and AppointmentsService. Catch potential errors from the Sinch API call, log them for debugging, and update the appointment status in the database accordingly.

Why use Docker Compose for the PostgreSQL database?

Docker Compose simplifies local database setup, providing a consistent environment. The provided `docker-compose.yml` file sets up a PostgreSQL container with defined credentials and port mappings.

Where can I find my Sinch API credentials?

Log in to your Sinch account dashboard, navigate to your project, and go to Settings -> API Credentials. There you'll find your Project ID, Key ID, and Key Secret.