Automated reminders and scheduled notifications are crucial for user engagement, appointment management, and timely communication. Building a reliable system to handle this requires careful consideration of scheduling, third-party integration, error handling, and scalability.
This guide provides a complete walkthrough for building a robust SMS scheduling and reminder application using the NestJS framework and the Plivo communications API. We will cover everything from initial project setup to deployment and monitoring, equipping you with the knowledge to build a production-grade solution.
What We'll Build:
- A NestJS application capable of accepting requests to schedule SMS messages for a future date and time.
- A reliable scheduling mechanism using
@nestjs/schedule
to check for due messages. - Integration with the Plivo API to send SMS messages.
- A REST API for creating and managing reminders.
- Database persistence for storing reminder details using TypeORM and PostgreSQL.
- Robust error handling, logging, and basic security measures.
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 task scheduling and configuration management.
- Plivo: A cloud communications platform providing SMS and Voice APIs. Chosen for its reliable SMS delivery and developer-friendly Node.js SDK.
@nestjs/schedule
: A NestJS module for handling cron jobs, timeouts, and intervals declaratively. Ideal for periodically checking for due reminders.- TypeORM: An ORM (Object-Relational Mapper) for TypeScript and JavaScript. Used for database interaction with PostgreSQL.
- PostgreSQL: A powerful, open-source object-relational database system.
@nestjs/config
: For managing environment variables securely and efficiently.class-validator
&class-transformer
: For robust request payload validation.
Prerequisites:
- Node.js (LTS version recommended) and npm/yarn installed.
- A Plivo account (Sign up for free credits).
- Access to a PostgreSQL database (local or cloud-hosted).
- Basic understanding of TypeScript, NestJS concepts, and REST APIs.
- Docker (optional, for containerized development and deployment).
System Architecture:
graph TD
subgraph ""User Interaction""
Client[Client Application / Postman] -->|POST /reminders| API{NestJS API Layer};
end
subgraph ""NestJS Application""
API -->|Create Reminder| RemindersService[Reminders Service];
RemindersService -->|Save Reminder| DB[(PostgreSQL Database)];
Scheduler[@nestjs/schedule Cron Job] -->|Check Due Reminders| RemindersService;
RemindersService -->|Fetch Due Reminders| DB;
RemindersService -->|Send SMS Request| PlivoService[Plivo Service];
PlivoService -->|Send SMS API Call| PlivoAPI[Plivo Cloud API];
API -->|Manage Reminders (GET, DELETE)| RemindersService;
RemindersService -->|Interact with DB| DB;
%% Internal Modules
API -- Uses --> ValidationPipe[Validation Pipe];
RemindersService -- Uses --> Logger[Logging Service];
PlivoService -- Uses --> ConfigService[Config Service for API Keys];
Scheduler -- Uses --> Logger;
end
subgraph ""External Services""
PlivoAPI -->|Sends SMS| UserPhone[User's Phone];
end
%% Styling
classDef nest fill:#e0234e,stroke:#a21b3b,color:#fff;
classDef db fill:#336791,stroke:#2a5274,color:#fff;
classDef ext fill:#f96d00,stroke:#c75600,color:#fff;
class API,RemindersService,PlivoService,Scheduler,ValidationPipe,Logger,ConfigService nest;
class DB db;
class PlivoAPI,UserPhone ext;
class Client client;
This diagram illustrates the flow: a client sends a request to the NestJS API to create a reminder. The RemindersService
validates and saves it to the database. A separate scheduled job (@nestjs/schedule
) periodically queries the database via the RemindersService
for due reminders. If found, it uses the PlivoService
(which securely accesses API keys via ConfigService
) to call the Plivo API, sending the SMS to the user's phone.
1. Setting up the Project
Let's initialize our NestJS project and install the necessary dependencies.
-
Create a new NestJS project:
npm install -g @nestjs/cli nest new nestjs-plivo-scheduler cd nestjs-plivo-scheduler
-
Install Dependencies: We need modules for scheduling, configuration, Plivo integration, database interaction (TypeORM, PostgreSQL driver), and validation.
npm install @nestjs/schedule @nestjs/config plivo typeorm @nestjs/typeorm pg class-validator class-transformer npm install --save-dev @types/cron
@nestjs/schedule
: For scheduling tasks.@nestjs/config
: For managing environment variables.plivo
: The official Plivo Node.js SDK.typeorm
,@nestjs/typeorm
,pg
: For database interaction with PostgreSQL using TypeORM.class-validator
,class-transformer
: For validating incoming request data.@types/cron
: Type definitions for the underlying cron library used by@nestjs/schedule
.
-
Environment Variables Setup: Create a
.env
file in the project root for storing sensitive information and configuration. Never commit this file to version control. Add it to your.gitignore
file if it's not already there.# .env # Application Port PORT=3000 # Plivo Credentials PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN PLIVO_SENDER_NUMBER=YOUR_PLIVO_PHONE_NUMBER # Must be a Plivo number in E.164 format (e.g., +14155552671) # Database Connection (PostgreSQL) DB_HOST=localhost DB_PORT=5432 DB_USERNAME=your_db_user DB_PASSWORD=your_db_password DB_DATABASE=scheduler_db # Cron Schedule (e.g., every minute) # See https://crontab.guru/ for syntax help REMINDER_CHECK_CRON_TIME=* * * * *
- How to get Plivo Credentials:
- Log in to your Plivo Console.
- Your Auth ID and Auth Token are displayed prominently on the dashboard homepage.
- Navigate to
Messaging
->Phone Numbers
->Numbers
in the left sidebar. Purchase or use an existing Plivo number. Copy the full number including the country code (e.g.,+12025550144
). This is yourPLIVO_SENDER_NUMBER
.
- Database Credentials: Replace the placeholders with your actual PostgreSQL connection details. Important: Ensure the database specified (
scheduler_db
in this example) exists on your PostgreSQL server before running the application or migrations. You may need to create it manually (e.g., usingCREATE DATABASE scheduler_db;
).
- How to get Plivo Credentials:
-
Configure Modules: Import the necessary modules into your main application module (
src/app.module.ts
).// src/app.module.ts import { Module, ValidationPipe } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { RemindersModule } from './reminders/reminders.module'; import { PlivoModule } from './plivo/plivo.module'; import { DatabaseModule } from './database/database.module'; // Optional import { Reminder } from './reminders/entities/reminder.entity'; // Import Reminder entity import { APP_PIPE } from '@nestjs/core'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Make ConfigModule available globally envFilePath: '.env', }), ScheduleModule.forRoot(), // Initialize the scheduler TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ type: 'postgres', host: configService.get<string>('DB_HOST'), port: configService.get<number>('DB_PORT'), username: configService.get<string>('DB_USERNAME'), password: configService.get<string>('DB_PASSWORD'), database: configService.get<string>('DB_DATABASE'), entities: [Reminder], // Register the Reminder entity synchronize: true, // WARNING: Set to false in production! Use migrations instead. autoLoadEntities: true, // Automatically load entities registered via forFeature() }), inject: [ConfigService], }), // DatabaseModule, // Optional: If you created a separate DB module RemindersModule, // Feature module for reminders PlivoModule, // Feature module for Plivo integration ], controllers: [AppController], providers: [ AppService, // Apply validation pipe globally { provide: APP_PIPE, useValue: new ValidationPipe({ whitelist: true, // Strip properties not defined in DTO transform: true, // Automatically transform payloads to DTO instances forbidNonWhitelisted: true, // Throw error if non-whitelisted properties are present }), }, ], }) export class AppModule {}
- We initialize
ConfigModule.forRoot
globally to access.env
variables anywhere. ScheduleModule.forRoot
enables task scheduling.TypeOrmModule.forRootAsync
sets up the database connection using credentials fromConfigService
.- Important:
synchronize: true
is convenient for development as it automatically updates the schema. In production, disable it (synchronize: false
) and use TypeORM Migrations to manage schema changes safely.
- Important:
- We register our future
RemindersModule
andPlivoModule
. - A global
ValidationPipe
is configured to automatically validate incoming request bodies based on DTOs.
- We initialize
-
Update
main.ts
: Enable shutdown hooks for graceful termination and set the application port from the environment variable.// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; import { Logger, ValidationPipe } from '@nestjs/common'; // Import Logger async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); const port = configService.get<number>('PORT', 3000); // Default to 3000 if not set // Global validation pipe is already configured in AppModule providers // app.useGlobalPipes(new ValidationPipe({ ... })); // This would be redundant app.enableShutdownHooks(); // Enable graceful shutdown await app.listen(port); Logger.log(`Application running on: http://localhost:${port}`, 'Bootstrap'); } bootstrap();
2. Creating a Database Schema and Data Layer
We need a way to store the reminders. We'll use TypeORM to define an entity and interact with the PostgreSQL database.
-
Define the
Reminder
Entity: Create the entity file that maps to our database table.// src/reminders/entities/reminder.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; export enum ReminderStatus { PENDING = 'PENDING', SENT = 'SENT', FAILED = 'FAILED', PROCESSING = 'PROCESSING', // Optional: To lock record during processing } @Entity('reminders') // Specifies the table name export class Reminder { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'varchar', length: 20 }) // Store E.164 format recipientPhoneNumber: string; @Column({ type: 'text' }) message: string; @Index() // Index this column for faster querying @Column({ type: 'timestamp with time zone' }) // Store with timezone awareness sendAt: Date; @Index() // Index this column for faster querying @Column({ type: 'enum', enum: ReminderStatus, default: ReminderStatus.PENDING, }) status: ReminderStatus; @Column({ type: 'text', nullable: true }) // Store Plivo message UUID plivoMessageUuid?: string; @Column({ type: 'text', nullable: true }) // Store any error message errorMessage?: string; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; }
- We define columns for the recipient's phone number, the message content, the scheduled time (
sendAt
), and the status (PENDING
,SENT
,FAILED
,PROCESSING
). sendAt
usestimestamp with time zone
to correctly handle different time zones. Always store dates in UTC in your database.- Indexes are added to
sendAt
andstatus
for efficient querying by the scheduler. plivoMessageUuid
anderrorMessage
are added for tracking and debugging.PROCESSING
status is optional but recommended to prevent a reminder from being picked up by multiple scheduler instances if the processing takes time.
- We define columns for the recipient's phone number, the message content, the scheduled time (
-
Create the
RemindersModule
: Generate the module, controller, and service using the Nest CLI.nest generate module reminders nest generate controller reminders --flat # No sub-directory for controller nest generate service reminders --flat # No sub-directory for service
--flat
prevents the CLI from creating unnecessary subdirectories withinsrc/reminders
.
-
Register Entity and Set Up Repository: Import
TypeOrmModule
intoRemindersModule
to make theReminder
repository available for injection.// src/reminders/reminders.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { RemindersController } from './reminders.controller'; import { RemindersService } from './reminders.service'; import { Reminder } from './entities/reminder.entity'; import { PlivoModule } from '../plivo/plivo.module'; // Import PlivoModule import { ScheduleModule } from '@nestjs/schedule'; // Import ScheduleModule if scheduling logic is here @Module({ imports: [ TypeOrmModule.forFeature([Reminder]), // Register Reminder entity/repository PlivoModule, // Make PlivoService available ScheduleModule, // Needed if @Cron decorator is in RemindersService ], controllers: [RemindersController], providers: [RemindersService], exports: [RemindersService], // Export if needed by other modules }) export class RemindersModule {}
-
(Production) Setting up Migrations (Recommended): As mentioned,
synchronize: true
is unsafe for production. Use TypeORM migrations instead.- Install CLI:
npm install -g typeorm
(or usenpx typeorm
) - Add Migration Scripts to
package.json
:// package.json { ""scripts"": { // ... other scripts ""typeorm"": ""ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --dataSource src/data-source.ts"", ""migration:generate"": ""npm run typeorm -- migration:generate src/migrations/%npm_config_name%"", ""migration:run"": ""npm run typeorm -- migration:run"", ""migration:revert"": ""npm run typeorm -- migration:revert"" } }
- Create Data Source File (
src/data-source.ts
): This file tells the TypeORM CLI how to connect to your database.// src/data-source.ts import { DataSource, DataSourceOptions } from 'typeorm'; import { config } from 'dotenv'; config(); // Load .env variables export const dataSourceOptions: DataSourceOptions = { type: 'postgres', host: process.env.DB_HOST, port: parseInt(process.env.DB_PORT || '5432', 10), username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, database: process.env.DB_DATABASE, entities: ['dist/**/*.entity{.ts,.js}'], // Point to compiled entities migrations: ['dist/migrations/*{.ts,.js}'], // Point to compiled migrations synchronize: false, // IMPORTANT: Set to false for migrations logging: true, }; const dataSource = new DataSource(dataSourceOptions); export default dataSource;
- Generate Initial Migration: After defining your entity, run:
This creates a migration file in
npm run migration:generate --name=InitialReminderSchema
src/migrations
. Review it. - Run Migrations:
This applies the migration to your database.
npm run build # Compile TypeScript to JavaScript first npm run migration:run
- Modify
TypeOrmModule.forRootAsync
: Update theentities
andmigrations
paths to point to the compiled output (dist
) and setsynchronize: false
. Note: Using__dirname
relies on the compiled file structure; verify paths based on your build output.// src/app.module.ts (inside TypeOrmModule.forRootAsync useFactory) // ... other options entities: [__dirname + '/../**/*.entity{.ts,.js}'], // Use dynamic path relative to compiled AppModule migrations: [__dirname + '/migrations/*{.ts,.js}'], // Use dynamic path relative to compiled AppModule synchronize: false, // Disable synchronize migrationsRun: true, // Optionally run migrations automatically on startup // ...
- Install CLI:
3. Integrating with Plivo
Let's create a dedicated module and service to handle interactions with the Plivo API.
-
Create the
PlivoModule
:nest generate module plivo nest generate service plivo --flat
-
Implement
PlivoService
: This service will initialize the Plivo client and provide a method to send SMS messages.// src/plivo/plivo.service.ts import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as plivo from 'plivo'; @Injectable() export class PlivoService implements OnModuleInit { private readonly logger = new Logger(PlivoService.name); private client: plivo.Client; private senderNumber: string; constructor(private readonly configService: ConfigService) {} onModuleInit() { const authId = this.configService.get<string>('PLIVO_AUTH_ID'); const authToken = this.configService.get<string>('PLIVO_AUTH_TOKEN'); this.senderNumber = this.configService.get<string>('PLIVO_SENDER_NUMBER'); if (!authId || !authToken || !this.senderNumber) { this.logger.error('Plivo credentials or sender number are missing in environment variables.'); // Consider throwing an error to prevent the application from starting incorrectly throw new Error('Missing Plivo configuration.'); } try { this.client = new plivo.Client(authId, authToken); this.logger.log('Plivo client initialized successfully.'); } catch (error) { this.logger.error('Failed to initialize Plivo client:', error); throw error; // Re-throw to indicate critical failure } } async sendSms(to: string, text: string): Promise<{ messageUuid: string | null; error?: string }> { if (!this.client) { this.logger.error('Plivo client not initialized. Cannot send SMS.'); return { messageUuid: null, error: 'Plivo client not initialized.' }; } // Basic E.164 format validation (can be more robust) if (!/^\+[1-9]\d{1,14}$/.test(to)) { this.logger.warn(`Invalid recipient phone number format: ${to}. Must be E.164.`); return { messageUuid: null, error: `Invalid recipient phone number format: ${to}. Must be E.164.` }; } if (!/^\+[1-9]\d{1,14}$/.test(this.senderNumber)) { this.logger.warn(`Invalid sender phone number format: ${this.senderNumber}. Must be E.164.`); return { messageUuid: null, error: `Invalid sender phone number format: ${this.senderNumber}. Must be E.164.` }; } this.logger.log(`Attempting to send SMS to ${to}`); try { const response = await this.client.messages.create( this.senderNumber, // src to, // dst text // text ); this.logger.log(`SMS sent successfully to ${to}. Message UUID: ${response.messageUuid[0]}`); // Plivo API returns messageUuid as an array, typically with one element return { messageUuid: response.messageUuid[0] }; } catch (error) { this.logger.error(`Failed to send SMS to ${to}:`, error?.message || error); // Extract a meaningful error message if possible const errorMessage = error?.message || 'Unknown Plivo API error'; return { messageUuid: null, error: errorMessage }; } } }
- It implements
OnModuleInit
to initialize the Plivo client once the module is ready. - It fetches credentials and the sender number from
ConfigService
. - Crucially, it includes error handling for missing configuration and during client initialization.
- The
sendSms
method wraps theclient.messages.create
call, performs basic E.164 validation, logs actions, and handles potential API errors gracefully, returning the message UUID or an error message.
- It implements
-
Register
ConfigService
inPlivoModule
:// src/plivo/plivo.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; // Import ConfigModule import { PlivoService } from './plivo.service'; @Module({ imports: [ConfigModule], // Make ConfigService available for injection providers: [PlivoService], exports: [PlivoService], // Export the service to be used elsewhere }) export class PlivoModule {}
4. Implementing Core Functionality (Scheduling and Sending)
Now, let's implement the service logic for creating reminders and the scheduled task to send them.
-
Implement
RemindersService
: Inject theReminder
repository andPlivoService
. Add methods to create reminders and process due ones.// src/reminders/reminders.service.ts import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThanOrEqual, In } from 'typeorm'; import { Cron, CronExpression } from '@nestjs/schedule'; import { ConfigService } from '@nestjs/config'; // Import ConfigService import { Reminder, ReminderStatus } from './entities/reminder.entity'; import { CreateReminderDto } from './dto/create-reminder.dto'; import { PlivoService } from '../plivo/plivo.service'; @Injectable() export class RemindersService { private readonly logger = new Logger(RemindersService.name); private readonly reminderCheckCronTime: string; constructor( @InjectRepository(Reminder) private readonly reminderRepository: Repository<Reminder>, private readonly plivoService: PlivoService, private readonly configService: ConfigService, // Inject ConfigService ) { // Get cron time from config, default to every minute if not set this.reminderCheckCronTime = this.configService.get<string>('REMINDER_CHECK_CRON_TIME', CronExpression.EVERY_MINUTE); this.logger.log(`Reminder check schedule initialized with cron: ${this.reminderCheckCronTime}`); } async create(createReminderDto: CreateReminderDto): Promise<Reminder> { const reminder = this.reminderRepository.create({ ...createReminderDto, sendAt: new Date(createReminderDto.sendAt), // Ensure sendAt is a Date object status: ReminderStatus.PENDING, }); this.logger.log(`Creating new reminder for ${reminder.recipientPhoneNumber} at ${reminder.sendAt}`); return this.reminderRepository.save(reminder); } // --- Scheduled Task --- // Note: @Cron decorator reads 'process.env' directly at load time. // It cannot dynamically use 'this.reminderCheckCronTime' from the constructor. // The constructor value is primarily for logging consistency here. @Cron(process.env.REMINDER_CHECK_CRON_TIME || CronExpression.EVERY_MINUTE) async handleCron() { this.logger.log(`[Cron Job] Checking for due reminders at ${new Date().toISOString()}`); const now = new Date(); let remindersToProcess: Reminder[] = []; let updatedCount = 0; // --- Step 1: Find and Lock Pending Reminders --- // This transaction attempts to prevent race conditions if multiple instances run await this.reminderRepository.manager.transaction(async transactionalEntityManager => { remindersToProcess = await transactionalEntityManager.find(Reminder, { where: { status: ReminderStatus.PENDING, sendAt: LessThanOrEqual(now), // Find reminders due now or earlier }, take: 100, // Process in batches to avoid overloading order: { sendAt: 'ASC', // Process older ones first }, }); if (remindersToProcess.length === 0) { this.logger.log('[Cron Job] No pending reminders found.'); return; // Exit transaction early if nothing to process } this.logger.log(`[Cron Job] Found ${remindersToProcess.length} reminders to process.`); // --- Step 2: Mark selected reminders as PROCESSING --- const idsToUpdate = remindersToProcess.map(r => r.id); const updateResult = await transactionalEntityManager.update( Reminder, { id: In(idsToUpdate), status: ReminderStatus.PENDING }, // Ensure status hasn't changed { status: ReminderStatus.PROCESSING, updatedAt: new Date() } ); updatedCount = updateResult.affected || 0; if (updatedCount !== remindersToProcess.length) { this.logger.warn(`[Cron Job] Concurrency issue detected. Expected to lock ${remindersToProcess.length}, but locked ${updatedCount}. Some reminders might be processed by another instance.`); // Filter remindersToProcess to only include those successfully updated // For simplicity, we proceed with those originally found, assuming the DB lock prevents double processing. // A more robust approach might re-query based on `updatedCount`. remindersToProcess = remindersToProcess.slice(0, updatedCount); // Adjust based on actual locked count } this.logger.log(`[Cron Job] Locked ${updatedCount} reminders for processing.`); }); // Transaction ends here // --- Step 3: Process Locked Reminders (Outside Transaction) --- if (updatedCount === 0) { // No reminders were successfully locked, possibly due to race condition or none found return; } // Process only the reminders that were successfully locked const successfullyLockedReminders = remindersToProcess; // Assuming the slice/filter above worked for (const reminder of successfullyLockedReminders) { this.logger.log(`[Cron Job] Processing reminder ID: ${reminder.id} for ${reminder.recipientPhoneNumber}`); try { const result = await this.plivoService.sendSms( reminder.recipientPhoneNumber, reminder.message, ); if (result.messageUuid) { // Update status to SENT and store message UUID await this.reminderRepository.update(reminder.id, { status: ReminderStatus.SENT, plivoMessageUuid: result.messageUuid, updatedAt: new Date(), errorMessage: null, // Clear any previous error }); this.logger.log(`[Cron Job] Reminder ID: ${reminder.id} successfully sent. Plivo UUID: ${result.messageUuid}`); } else { // Update status to FAILED and store error message await this.reminderRepository.update(reminder.id, { status: ReminderStatus.FAILED, errorMessage: result.error || 'Unknown Plivo sending error', updatedAt: new Date(), }); this.logger.error(`[Cron Job] Failed to send reminder ID: ${reminder.id}. Error: ${result.error}`); } } catch (error) { // Catch unexpected errors during processing or DB update this.logger.error(`[Cron Job] Unexpected error processing reminder ID: ${reminder.id}`, error); try { // Attempt to mark as FAILED even if an unexpected error occurred await this.reminderRepository.update(reminder.id, { status: ReminderStatus.FAILED, errorMessage: `Unexpected processing error: ${error.message}`, updatedAt: new Date(), }); } catch (updateError) { this.logger.error(`[Cron Job] CRITICAL: Failed to update status for reminder ID: ${reminder.id} after processing error`, updateError); // This reminder might remain in PROCESSING state and needs manual intervention or a cleanup job. } } } this.logger.log(`[Cron Job] Finished processing batch of ${updatedCount} reminders.`); } // --- Basic CRUD Methods (for API) --- async findAll(status?: ReminderStatus): Promise<Reminder[]> { this.logger.log(`Finding all reminders${status ? ` with status: ${status}` : ''}`); return this.reminderRepository.find({ where: status ? { status } : {} }); } async findOne(id: string): Promise<Reminder> { this.logger.log(`Finding reminder with ID: ${id}`); const reminder = await this.reminderRepository.findOne({ where: { id } }); if (!reminder) { throw new NotFoundException(`Reminder with ID ""${id}"" not found`); } return reminder; } async remove(id: string): Promise<void> { this.logger.log(`Attempting to remove reminder with ID: ${id}`); const result = await this.reminderRepository.delete(id); if (result.affected === 0) { throw new NotFoundException(`Reminder with ID ""${id}"" not found`); } this.logger.log(`Successfully removed reminder with ID: ${id}`); } // Optional: Method to retry failed reminders async retryFailedReminder(id: string): Promise<Reminder> { this.logger.log(`Attempting to retry failed reminder ID: ${id}`); const reminder = await this.findOne(id); // findOne includes NotFoundException check if (reminder.status !== ReminderStatus.FAILED) { throw new ConflictException(`Reminder ID: ${id} is not in FAILED state. Current state: ${reminder.status}`); } // Reset status to PENDING so the cron job picks it up again reminder.status = ReminderStatus.PENDING; reminder.errorMessage = null; // Clear previous error reminder.updatedAt = new Date(); return this.reminderRepository.save(reminder); } }
create
: Takes validated DTO data, creates aReminder
entity instance (ensuringsendAt
is aDate
), sets the initial status toPENDING
, and saves it using the repository.handleCron
:- Decorated with
@Cron
to run based on theREMINDER_CHECK_CRON_TIME
environment variable (defaulting to every minute). Important: The@Cron
decorator readsprocess.env
directly when the application loads, not thethis.reminderCheckCronTime
instance variable dynamically. - Uses a transaction (
this.reminderRepository.manager.transaction
) for finding and updating reminders to prevent race conditions in multi-instance deployments. - Finds pending reminders (
status: ReminderStatus.PENDING
) whosesendAt
time is less than or equal to the current time (now
). It processes in batches (take: 100
) and prioritizes older reminders (order: { sendAt: 'ASC' }
). - Locks the found reminders by updating their status to
PROCESSING
within the transaction. It checks if the number of affected rows matches the number found to detect potential concurrency issues where another instance might have processed some reminders between thefind
andupdate
steps. - Processes the locked reminders outside the initial transaction to avoid holding the transaction open during potentially long-running external API calls (SMS sending).
- For each reminder, it calls
plivoService.sendSms
. - Based on the Plivo result, it updates the reminder status to
SENT
(with Plivo message UUID) orFAILED
(with an error message). - Includes robust error handling for both Plivo API errors and unexpected errors during processing or database updates.
- Decorated with
findAll
,findOne
,remove
: Standard CRUD methods for managing reminders via the API, including logging andNotFoundException
handling.retryFailedReminder
: An optional method to reset aFAILED
reminder back toPENDING
so the cron job can attempt to send it again. Includes checks to ensure the reminder is actually in aFAILED
state.