diff --git a/README.md b/README.md index 35ec5ca..9fc8cd4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @ciscode/notification-kit -> A flexible, type-safe notification system for NestJS applications supporting multiple channels (Email, SMS, Push) with pluggable providers. +> A lightweight, delivery-focused notification library for NestJS. Send notifications through multiple channels (Email, SMS, Push, WhatsApp) with pluggable providers. **Your app manages content and templates, NotificationKit handles delivery.** [![npm version](https://img.shields.io/npm/v/@ciscode/notification-kit.svg)](https://www.npmjs.com/package/@ciscode/notification-kit) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -8,17 +8,20 @@ ## ✨ Features -- šŸš€ **Multi-Channel Support** - Email, SMS, and Push notifications in one unified interface +- šŸŽÆ **Lightweight & Focused** - Does one thing well: delivers notifications. No bloat, no unnecessary dependencies. +- šŸš€ **Multi-Channel Support** - Email, SMS, Push, and WhatsApp notifications in one unified interface - šŸ”Œ **Pluggable Providers** - Support for multiple providers (Twilio, AWS SNS, Firebase, Nodemailer, etc.) +- šŸ“± **WhatsApp Support** - Send WhatsApp messages with media support via Twilio API - šŸŽÆ **NestJS First** - Built specifically for NestJS with dependency injection support - šŸ“¦ **Framework Agnostic Core** - Clean architecture with framework-independent domain logic - šŸ”„ **Retry & Queue Management** - Built-in retry logic and notification state management - šŸ“Š **Event System** - Track notification lifecycle with event emitters -- šŸŽØ **Template Support** - Handlebars and simple template engines included - šŸ’¾ **Flexible Storage** - MongoDB, PostgreSQL, or custom repository implementations - āœ… **Fully Tested** - Comprehensive test suite with 133+ tests - šŸ”’ **Type Safe** - Written in TypeScript with full type definitions +> **šŸ“ Design Philosophy**: NotificationKit is a **delivery library**, not a content management system. Your application should manage templates, content, and business logic. NotificationKit focuses solely on reliable multi-channel delivery. + ## šŸ“¦ Installation ```bash @@ -39,6 +42,9 @@ npm install twilio # Twilio npm install @aws-sdk/client-sns # AWS SNS npm install @vonage/server-sdk # Vonage +# For WhatsApp +npm install twilio # Twilio WhatsApp API + # For push notifications (choose one) npm install firebase-admin # Firebase npm install @aws-sdk/client-sns # AWS SNS @@ -148,7 +154,132 @@ POST /notifications/:id/retry POST /notifications/:id/cancel ``` -## šŸ“š Documentation +## ļæ½ WhatsApp Support + +NotificationKit now supports WhatsApp messaging via Twilio's WhatsApp API with full media and template support! + +### Setup WhatsApp Sender + +```typescript +import { TwilioWhatsAppSender, MockWhatsAppSender } from "@ciscode/notification-kit"; + +// For production (real Twilio API) +NotificationKitModule.register({ + senders: [ + new TwilioWhatsAppSender({ + accountSid: process.env.TWILIO_ACCOUNT_SID, + authToken: process.env.TWILIO_AUTH_TOKEN, + fromNumber: process.env.TWILIO_WHATSAPP_FROM, // e.g., '+14155238886' + templates: { + orderShipped: "order_shipped_v1", + welcomeMessage: "welcome_v2", + }, + }), + ], + // ... other config +}); + +// For development/testing (no credentials needed) +NotificationKitModule.register({ + senders: [new MockWhatsAppSender({ logMessages: true })], + // ... other config +}); +``` + +### Send WhatsApp Messages + +#### Basic Text Message + +```typescript +await notificationService.send({ + channel: NotificationChannel.WHATSAPP, + priority: NotificationPriority.HIGH, + recipient: { + id: "user-123", + phone: "+14155551234", // E.164 format required + }, + content: { + title: "Order Update", + body: "Your order #12345 has been shipped!", + }, +}); +``` + +#### WhatsApp with Media (Images/PDFs/Videos) + +```typescript +await notificationService.send({ + channel: NotificationChannel.WHATSAPP, + recipient: { + id: "user-456", + phone: "+447911123456", + }, + content: { + title: "Invoice Ready", + body: "Your invoice is attached", + data: { + mediaUrl: "https://example.com/invoice.pdf", + }, + }, +}); +``` + +#### WhatsApp with Templates + +```typescript +await notificationService.send({ + channel: NotificationChannel.WHATSAPP, + recipient: { + id: "user-789", + phone: "+212612345678", + }, + content: { + title: "OTP Code", + body: "Your verification code is {{code}}", + templateId: "otp_verification", + templateVars: { + code: "123456", + expiryMinutes: "5", + }, + }, +}); +``` + +### WhatsApp Requirements + +- **Phone Format**: Must be E.164 format (`+[country code][number]`) + - āœ… Valid: `+14155551234`, `+447911123456`, `+212612345678` + - āŒ Invalid: `4155551234`, `+1-415-555-1234`, `+1 (415) 555-1234` +- **Twilio Account**: Required for production use +- **WhatsApp Opt-in**: Recipients must opt-in to receive messages (send "join [code]" to Twilio number) +- **Media Support**: Images, videos, audio, PDFs (max 16MB for videos, 5MB for images) +- **Templates**: Some message types require pre-approved WhatsApp templates + +### Testing WhatsApp Without Twilio + +Use `MockWhatsAppSender` for development: + +```typescript +const mockSender = new MockWhatsAppSender({ logMessages: true }); + +// Simulates sending and logs to console +// No actual API calls or credentials needed +``` + +Console output example: + +``` +═══════════════════════════════════════════ +šŸ“± [MockWhatsApp] Simulating WhatsApp send +═══════════════════════════════════════════ +To: +14155551234 +Recipient ID: user-123 + +šŸ’¬ Message: Your order has been shipped! +═══════════════════════════════════════════ +``` + +## ļæ½šŸ“š Documentation ### Core Concepts @@ -157,6 +288,7 @@ POST /notifications/:id/cancel - **EMAIL** - Email notifications via SMTP providers - **SMS** - Text messages via SMS gateways - **PUSH** - Mobile push notifications +- **WHATSAPP** - WhatsApp messages via Twilio or Meta Business API - **WEBHOOK** - HTTP callbacks (coming soon) #### Notification Status Lifecycle @@ -188,6 +320,11 @@ CANCELLED - **AwsSnsSender** - AWS SNS for SMS - **VonageSmsSender** - Vonage (formerly Nexmo) +#### WhatsApp Senders + +- **TwilioWhatsAppSender** - Twilio WhatsApp API (supports media & templates) +- **MockWhatsAppSender** - Mock sender for testing without credentials + #### Push Notification Senders - **FirebasePushSender** - Firebase Cloud Messaging (FCM) @@ -281,14 +418,7 @@ NotificationKitModule.registerAsync({ }), ], repository: new MongooseNotificationRepository(/* connection */), - templateEngine: new HandlebarsTemplateEngine({ - templates: { - welcome: { - title: "Welcome {{name}}!", - body: "Hello {{name}}, thanks for joining {{appName}}!", - }, - }, - }), + // templateEngine: optional - most apps manage templates in backend eventEmitter: new InMemoryEventEmitter(), }), inject: [ConfigService], @@ -317,37 +447,74 @@ eventEmitter.on("*", (event) => { }); ``` -### Template Rendering +### Content Management + +> āš ļø **Best Practice**: Manage templates and content in your backend application, not in NotificationKit. Your app knows your business logic, user preferences, and localization needs better than a delivery library. + +**Recommended Approach** (Render in Your Backend): ```typescript -import { HandlebarsTemplateEngine } from "@ciscode/notification-kit/infra"; +@Injectable() +export class NotificationService { + constructor( + private templateService: TemplateService, // Your template service + private notificationKit: NotificationService, // From NotificationKit + ) {} -const templateEngine = new HandlebarsTemplateEngine({ - templates: { + async sendWelcomeEmail(user: User) { + // 1. Your backend renders the template + const content = await this.templateService.render("welcome", { + name: user.name, + appName: "MyApp", + }); + + // 2. NotificationKit delivers it + await this.notificationKit.send({ + channel: NotificationChannel.EMAIL, + recipient: { id: user.id, email: user.email }, + content: { + title: content.subject, + body: content.text, + html: content.html, + }, + }); + } +} +``` + +**Built-in Template Engine** (Optional, for simple use cases): + +NotificationKit includes optional template engines for quick prototyping: + +```typescript +import { SimpleTemplateEngine } from "@ciscode/notification-kit/infra"; + +// Only use for demos/prototyping +NotificationKitModule.register({ + templateEngine: new SimpleTemplateEngine({ welcome: { title: "Welcome {{name}}!", body: "Hello {{name}}, welcome to {{appName}}!", - html: "

Welcome {{name}}!

Thanks for joining {{appName}}!

", }, - }, + }), }); -// Use in notification +// Send using template await notificationService.send({ - channel: NotificationChannel.EMAIL, - recipient: { id: "user-123", email: "user@example.com" }, content: { templateId: "welcome", - templateVars: { - name: "John Doe", - appName: "My App", - }, + templateVars: { name: "John", appName: "MyApp" }, }, }); ``` ### Webhook Handling +> **Note**: Built-in templates are optional and best suited for prototyping. Production apps should manage templates in the backend for flexibility, versioning, and localization. See [Template Configuration Guide](./docs/TEMPLATE_CONFIGURATION.md) for details. + +```` + + Enable webhook endpoints to receive delivery notifications from providers: ```typescript @@ -356,7 +523,7 @@ NotificationKitModule.register({ webhookSecret: process.env.WEBHOOK_SECRET, // ... other options }); -``` +```` Webhook endpoint: `POST /notifications/webhook` diff --git a/docs/TEMPLATE_CONFIGURATION.md b/docs/TEMPLATE_CONFIGURATION.md new file mode 100644 index 0000000..5c43940 --- /dev/null +++ b/docs/TEMPLATE_CONFIGURATION.md @@ -0,0 +1,604 @@ +# Template Configuration Guide + +> āš ļø **Important Design Principle**: NotificationKit is a **delivery-focused library**, not a content management system. For production applications, you should manage templates in your backend application where you have full control over content, localization, versioning, and business logic. + +This guide explains template configuration in NotificationKit for different scenarios: + +- **Provider templates** (required for WhatsApp) - Always needed +- **Built-in template engine** (optional) - Use only for prototyping + +## When to Use Built-in Templates + +**āœ… Good for:** + +- Quick prototypes and demos +- Simple use cases with static templates +- Learning and testing NotificationKit + +**āŒ Not recommended for:** + +- Production applications +- Multi-language support +- Content that changes frequently +- Complex personalization logic +- A/B testing content +- Template versioning and history + +## Recommended: Manage Templates in Your Backend + +For production apps, manage templates in your backend: + +```typescript +// Your backend handles templates +@Injectable() +export class EmailService { + async sendWelcome(user: User) { + // 1. Load & render template from YOUR system (DB, CMS, files) + const template = await this.templateRepo.findByName("welcome"); + const content = await this.renderEngine.render(template, { + name: user.name, + locale: user.locale, + }); + + // 2. NotificationKit delivers pre-rendered content + await this.notificationService.send({ + channel: NotificationChannel.EMAIL, + recipient: { id: user.id, email: user.email }, + content: { + title: content.subject, + body: content.text, + html: content.html, + }, + }); + } +} +``` + +**Benefits:** + +- āœ… Update templates without redeploying code +- āœ… Store in database with version history +- āœ… Use any template engine (Handlebars, Pug, EJS, React Email, etc.) +- āœ… Implement i18n properly +- āœ… A/B test different content +- āœ… Separate concerns: content vs delivery + +--- + +## Two Template Systems + +NotificationKit provides **two optional template systems** for simple use cases: + +1. **Provider-Specific Templates** - Channel-specific templates (e.g., WhatsApp/Twilio templates) +2. **Global Template Engine** - Cross-channel template system for dynamic content + +--- + +## 1. Provider-Specific Templates + +### WhatsApp Templates (Twilio) + +WhatsApp Business API requires pre-approved templates. Configure them in the sender: + +```typescript +import { TwilioWhatsAppSender } from "@ciscode/notification-kit"; + +new TwilioWhatsAppSender({ + accountSid: process.env.TWILIO_ACCOUNT_SID!, + authToken: process.env.TWILIO_AUTH_TOKEN!, + fromNumber: "+14155238886", + + // Map your app's template IDs to Twilio's approved templates + templates: { + orderShipped: "order_shipped_v1", + orderConfirmed: "order_confirmed_v2", + otp: "otp_verification_v3", + welcomeMessage: "welcome_user_v1", + }, +}); +``` + +**Usage:** + +```typescript +await notificationService.send({ + channel: NotificationChannel.WHATSAPP, + recipient: { phone: "+14155551234" }, + content: { + templateId: "orderShipped", // Maps to 'order_shipped_v1' + templateVars: { + orderNumber: "12345", + trackingUrl: "https://track.com/12345", + }, + }, +}); +``` + +**Setup Steps:** + +1. Create templates in [Twilio Console](https://console.twilio.com) → Content API +2. Get templates approved by Twilio/Meta +3. Map approved template names in your config +4. Use `templateId` to reference them + +--- + +## 2. Global Template Engine + +Use the global template engine for dynamic content across **all channels** (Email, SMS, Push, WhatsApp). + +### Option A: SimpleTemplateEngine + +Simple string replacement with `{{variable}}` syntax. + +```typescript +import { NotificationKitModule } from '@ciscode/notification-kit'; +import { SimpleTemplateEngine } from '@ciscode/notification-kit/infra'; + +NotificationKitModule.register({ + senders: [/* ... */], + repository: /* ... */, + + templateEngine: new SimpleTemplateEngine({ + // Email templates + 'welcome-email': { + title: 'Welcome to {{appName}}!', + body: 'Hello {{userName}}, welcome to our platform.', + html: '

Welcome {{userName}}!

', + }, + + 'password-reset': { + title: 'Reset Your Password', + body: 'Use this code: {{code}}', + html: '

Code: {{code}}

', + }, + + // SMS templates + 'sms-otp': { + title: 'Verification Code', + body: 'Your code is {{code}}. Valid for {{minutes}} minutes.', + }, + + // Push notification templates + 'push-new-message': { + title: 'New Message from {{senderName}}', + body: '{{preview}}', + }, + }), +}); +``` + +**Features:** + +- āœ… Simple `{{variable}}` replacement +- āœ… No dependencies +- āœ… Fast and lightweight +- āŒ No conditionals or loops + +**Usage:** + +```typescript +// Email +await notificationService.send({ + channel: NotificationChannel.EMAIL, + recipient: { email: "user@example.com" }, + content: { + templateId: "welcome-email", + templateVars: { appName: "MyApp", userName: "John" }, + }, +}); + +// SMS +await notificationService.send({ + channel: NotificationChannel.SMS, + recipient: { phone: "+14155551234" }, + content: { + templateId: "sms-otp", + templateVars: { code: "123456", minutes: "5" }, + }, +}); +``` + +--- + +### Option B: HandlebarsTemplateEngine + +Advanced templating with conditionals, loops, and helpers. + +```typescript +import { HandlebarsTemplateEngine } from "@ciscode/notification-kit/infra"; + +NotificationKitModule.register({ + templateEngine: new HandlebarsTemplateEngine({ + templates: { + "welcome-email": { + title: "Welcome {{name}}!", + body: ` + Hello {{name}}, + + {{#if isPremium}} + Welcome to Premium! šŸŽ‰ + {{else}} + Upgrade to Premium for exclusive features. + {{/if}} + + Features: + {{#each features}} + - {{this}} + {{/each}} + `, + html: ` +

Welcome {{name}}!

+ {{#if isPremium}} +
Premium
+ {{/if}} + + `, + }, + + "order-summary": { + title: "Order #{{orderId}} - ${{total}}", + body: ` + Order Confirmed! + + Items: + {{#each items}} + - {{name}} x{{qty}} = ${{ price }} + {{/each}} + + Subtotal: ${{ subtotal }} + Tax: ${{ tax }} + Total: ${{ total }} + + {{#if shippingAddress}} + Shipping to: + {{shippingAddress.street}} + {{shippingAddress.city}}, {{shippingAddress.zip}} + {{/if}} + `, + }, + }, + }), +}); +``` + +**Features:** + +- āœ… Conditionals: `{{#if}}`, `{{#unless}}` +- āœ… Loops: `{{#each}}` +- āœ… Nested objects: `{{user.name}}` +- āœ… Helpers: Custom functions +- āš ļø Requires `handlebars` peer dependency + +**Installation:** + +```bash +npm install handlebars +``` + +**Usage:** + +```typescript +await notificationService.send({ + channel: NotificationChannel.EMAIL, + recipient: { email: "user@example.com" }, + content: { + templateId: "order-summary", + templateVars: { + orderId: "12345", + items: [ + { name: "Product A", qty: 2, price: 29.99 }, + { name: "Product B", qty: 1, price: 49.99 }, + ], + subtotal: 109.97, + tax: 11.0, + total: 120.97, + shippingAddress: { + street: "123 Main St", + city: "San Francisco", + zip: "94105", + }, + }, + }, +}); +``` + +--- + +## Complete Configuration Example + +Here's a full example using both template systems: + +```typescript +import { Module } from "@nestjs/common"; +import { + NotificationKitModule, + TwilioWhatsAppSender, + NodemailerSender, + TwilioSmsSender, + HandlebarsTemplateEngine, + InMemoryNotificationRepository, +} from "@ciscode/notification-kit"; + +@Module({ + imports: [ + NotificationKitModule.register({ + // Configure senders with channel-specific templates + senders: [ + // Email sender (uses global template engine) + new NodemailerSender({ + host: "smtp.gmail.com", + port: 587, + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, + from: "noreply@myapp.com", + }), + + // SMS sender (uses global template engine) + new TwilioSmsSender({ + accountSid: process.env.TWILIO_ACCOUNT_SID!, + authToken: process.env.TWILIO_AUTH_TOKEN!, + fromNumber: "+14155551234", + }), + + // WhatsApp sender (has its OWN templates) + new TwilioWhatsAppSender({ + accountSid: process.env.TWILIO_ACCOUNT_SID!, + authToken: process.env.TWILIO_AUTH_TOKEN!, + fromNumber: "+14155238886", + + // WhatsApp-specific templates (pre-approved on Twilio) + templates: { + orderShipped: "order_shipped_v1", + otp: "otp_verification_v2", + welcomeMessage: "welcome_user_v1", + }, + }), + ], + + // Global template engine for ALL channels + templateEngine: new HandlebarsTemplateEngine({ + templates: { + // Email templates + "welcome-email": { + title: "Welcome to {{appName}}!", + body: "Hello {{userName}}, welcome to our platform!", + html: "

Welcome {{userName}}!

", + }, + + "password-reset-email": { + title: "Reset Your Password", + body: "Click here: {{resetLink}}", + html: 'Reset Password', + }, + + // SMS templates + "sms-verification": { + title: "Verification Code", + body: "Your code: {{code}}. Valid {{minutes}}min.", + }, + + "sms-alert": { + title: "Alert", + body: "{{message}}", + }, + + // Push notification templates + "push-new-message": { + title: "New message from {{senderName}}", + body: "{{preview}}", + }, + + // WhatsApp fallback templates (when not using Twilio templates) + "whatsapp-custom": { + title: "Order Update", + body: "Hi {{name}}, your order #{{id}} is {{status}}", + }, + }, + }), + + repository: new InMemoryNotificationRepository(), + }), + ], +}) +export class AppModule {} +``` + +--- + +## Usage Examples + +### Email with Global Template + +```typescript +await notificationService.send({ + channel: NotificationChannel.EMAIL, + recipient: { id: "user-1", email: "john@example.com" }, + content: { + templateId: "welcome-email", + templateVars: { appName: "MyApp", userName: "John" }, + }, +}); +``` + +### SMS with Global Template + +```typescript +await notificationService.send({ + channel: NotificationChannel.SMS, + recipient: { id: "user-2", phone: "+14155551234" }, + content: { + templateId: "sms-verification", + templateVars: { code: "123456", minutes: "5" }, + }, +}); +``` + +### WhatsApp with Provider Template + +```typescript +await notificationService.send({ + channel: NotificationChannel.WHATSAPP, + recipient: { id: "user-3", phone: "+447911123456" }, + content: { + templateId: "orderShipped", // Uses Twilio's 'order_shipped_v1' + templateVars: { + orderNumber: "12345", + trackingUrl: "https://track.com/12345", + }, + }, +}); +``` + +### WhatsApp with Global Template (Fallback) + +```typescript +await notificationService.send({ + channel: NotificationChannel.WHATSAPP, + recipient: { id: "user-4", phone: "+212612345678" }, + content: { + templateId: "whatsapp-custom", // Uses global template engine + templateVars: { + name: "Alice", + id: "67890", + status: "shipped", + }, + }, +}); +``` + +--- + +## Template Priority + +When sending a notification with a `templateId`: + +1. **Check provider-specific templates first** (e.g., WhatsApp sender's `templates` map) + +### šŸŽÆ Primary Recommendation: Backend Template Management + +**For production applications**, manage templates in your backend: + +```typescript +// āœ… Best: Full control in your backend +class TemplateService { + async render(name: string, vars: any, locale: string) { + const template = await this.db.templates.findOne({ name, locale }); + return this.engine.render(template, vars); + } +} +``` + +**Benefits:** + +- Update templates without code deployment +- Store in database with full audit trail +- Support multiple languages properly +- A/B test content variations +- Use any template engine you prefer + +2. **Fall back to global template engine** if not found in provider + +- **Manage templates in your backend** for production apps (recommended) + +3. **Use raw content** if no templates match + +--- + +## Best Practices + +### āœ… Do: + +- Use built-in templates for production (manage in backend instead) +- Use **provider templates** for WhatsApp (required by Twilio/Meta) +- Pass **pre-rendered content** to NotificationKit +- Use built-in templates **only for prototypes/demos** +- Keep templates **simple and reusable** (if using built-in) +- Store production templates in NotificationKit config +- Test templates with **real data** +- Version your templates (e.g., `welcome_v2`) + +### āŒ Don't: + +- Hard-code content in your app logic +- Mix template systems unnecessarily +- Forget to handle missing variables +- Use complex logic in templates (move to app code) + +--- + +## Configuration File Organization + +For large apps, organize templates in separate files: + +```typescript +// templates/email.templates.ts +export const emailTemplates = { + 'welcome-email': { + title: 'Welcome to {{appName}}!', + body: '...', + html: '...', + }, + // ... more email templates +}; + +// templates/sms.templates.ts +export const smsTemplates = { + 'sms-otp': { ... }, + 'sms-alert': { ... }, +}; + +// templates/whatsapp.config.ts +export const whatsappTemplates = { + orderShipped: 'order_shipped_v1', + otp: 'otp_verification_v2', +}; + +// app.module.ts +import { emailTemplates } from './templates/email.templates'; +import { smsTemplates } from './templates/sms.templates'; +import { whatsappTemplates } from './templates/whatsapp.config'; + +NotificationKitModule.register({ + senders: [ + new TwilioWhatsAppSender({ + // ... + templates: whatsappTemplates, + }), + ], + templateEngine: new HandlebarsTemplateEngine({ + templates: { + ...emailTemplates, + ...smsTemplates, + }, + }), +}); +``` + +--- + +## Summary + +| Template Type | Where Configured | Used For | Example | +| --------------------- | ---------------- | --------------------------- | ----------------------------------------------------- | +| **Provider-Specific** | Sender config | WhatsApp (Twilio templates) | `new TwilioWhatsAppSender({ templates: {...} })` | +| **Global Engine** | Module config | Email, SMS, Push, WhatsApp | `templateEngine: new HandlebarsTemplateEngine({...})` | + +**Key Difference:** + +- **Provider templates** = Pre-approved by provider (Twilio/Meta) +- **Global templates** = Your own custom templates for any channel + +--- + +## Need Help? + +- Check [Core Documentation](../src/core/README.md) +- See [Infrastructure Providers](../src/infra/README.md) +- Review [Test Examples](../src/core/notification.service.test.ts) diff --git a/src/core/dtos.ts b/src/core/dtos.ts index 8cbfe85..77e2c3e 100644 --- a/src/core/dtos.ts +++ b/src/core/dtos.ts @@ -1,45 +1,149 @@ +/** + * Data Transfer Objects (DTOs) with Zod Validation + * + * This file defines all DTOs (Data Transfer Objects) used for validating input + * across the NotificationKit API. It uses Zod for runtime type validation and + * schema enforcement. + * + * Why DTOs with Zod? + * - Runtime validation: Ensure data is valid before business logic runs + * - Type safety: TypeScript types automatically inferred from schemas + * - Clear error messages: Detailed validation errors for API consumers + * - Schema documentation: Self-documenting API contracts + * + * DTOs defined here: + * 1. CreateNotificationDto - For creating single notifications + * 2. SendNotificationDto - For immediate sending (alias of CreateNotificationDto) + * 3. QueryNotificationsDto - For searching/filtering notifications + * 4. UpdateNotificationStatusDto - For updating notification status + * 5. BulkSendNotificationDto - For sending to multiple recipients + * + * Usage: + * ```typescript + * // Validate and parse + * const dto = validateDto(CreateNotificationDtoSchema, requestBody); + * + * // Safe validation (doesn't throw) + * const result = validateDtoSafe(CreateNotificationDtoSchema, requestBody); + * if (result.success) { + * // Use result.data + * } else { + * // Handle result.errors + * } + * ``` + */ + import { z } from "zod"; import { NotificationChannel, NotificationPriority } from "./types"; /** - * Zod schema for notification recipient + * Schema for notification recipient information + * + * Defines who will receive the notification. Different channels require + * different contact information: + * - EMAIL channel: requires 'email' field + * - SMS channel: requires 'phone' field + * - PUSH channel: requires 'deviceToken' field + * - IN_APP/WEBHOOK channels: only require 'id' + * + * Fields: + * - id: Unique identifier for the recipient (user ID, customer ID, etc.) + * - email: Email address (required for EMAIL channel) + * - phone: Phone number in E.164 format (required for SMS channel) + * - deviceToken: FCM/APNS device token (required for PUSH channel) + * - metadata: Additional recipient data for logging/analytics */ export const NotificationRecipientSchema = z.object({ - id: z.string().min(1, "Recipient ID is required"), - email: z.string().email().optional(), - phone: z.string().optional(), - deviceToken: z.string().optional(), - metadata: z.record(z.unknown()).optional(), + id: z.string().min(1, "Recipient ID is required"), // Unique recipient identifier + email: z.string().email().optional(), // Email address (for EMAIL channel) + phone: z.string().optional(), // Phone number (for SMS channel) + deviceToken: z.string().optional(), // FCM/APNS token (for PUSH channel) + metadata: z.record(z.unknown()).optional(), // Additional data (user preferences, etc.) }); /** - * Zod schema for notification content + * Schema for notification content + * + * Defines what will be sent in the notification. Supports both direct content + * and template-based content. + * + * Direct content mode: + * - Provide title, body, and (optionally) html + * - Content is used as-is + * + * Template mode: + * - Provide templateId (e.g., "welcome-email", "password-reset") + * - Provide templateVars for variable substitution (e.g., { name: "John", code: "123456" }) + * - Template engine renders title/body/html from template + * + * Fields: + * - title: Notification title/subject (email subject, push title, etc.) + * - body: Main notification text content (plain text) + * - html: HTML version of body (for email) + * - data: Additional structured data (for push notifications, webhooks) + * - templateId: ID of template to render (optional) + * - templateVars: Variables for template rendering (optional) */ export const NotificationContentSchema = z.object({ - title: z.string().min(1, "Title is required"), - body: z.string().min(1, "Body is required"), - html: z.string().optional(), - data: z.record(z.unknown()).optional(), - templateId: z.string().optional(), - templateVars: z.record(z.unknown()).optional(), + title: z.string().min(1, "Title is required"), // Required: notification title/subject + body: z.string().min(1, "Body is required"), // Required: main text content + html: z.string().optional(), // Optional: HTML version (for email) + data: z.record(z.unknown()).optional(), // Optional: additional data payload + templateId: z.string().optional(), // Optional: template to render + templateVars: z.record(z.unknown()).optional(), // Optional: variables for template }); /** - * DTO for creating a new notification + * Schema for creating a new notification + * + * This is the primary DTO for creating notifications. It includes comprehensive + * validation rules: + * + * 1. Channel validation: Must be a valid NotificationChannel enum value + * 2. Priority validation: Must be a valid NotificationPriority (default: NORMAL) + * 3. Recipient validation: Must pass NotificationRecipientSchema checks + * 4. Content validation: Must pass NotificationContentSchema checks + * 5. Schedule validation: If provided, must be valid ISO 8601 datetime + * 6. Retry validation: Must be 0-10 (default: 3) + * 7. Cross-field validation: Recipient must have appropriate contact info for channel + * - EMAIL channel → recipient.email required + * - SMS channel → recipient.phone required + * - PUSH channel → recipient.deviceToken required + * + * Example valid DTO: + * ```json + * { + * "channel": "email", + * "priority": "high", + * "recipient": { + * "id": "user-123", + * "email": "user@example.com" + * }, + * "content": { + * "title": "Welcome!", + * "body": "Welcome to our platform", + * "html": "

Welcome!

" + * }, + * "scheduledFor": "2026-04-01T10:00:00Z", + * "maxRetries": 3, + * "metadata": { "campaign": "onboarding" } + * } + * ``` */ export const CreateNotificationDtoSchema = z .object({ - channel: z.nativeEnum(NotificationChannel), - priority: z.nativeEnum(NotificationPriority).default(NotificationPriority.NORMAL), - recipient: NotificationRecipientSchema, - content: NotificationContentSchema, - scheduledFor: z.string().datetime().optional(), - maxRetries: z.number().int().min(0).max(10).default(3), - metadata: z.record(z.unknown()).optional(), + channel: z.nativeEnum(NotificationChannel), // Which channel to send through + priority: z.nativeEnum(NotificationPriority).default(NotificationPriority.NORMAL), // Priority level + recipient: NotificationRecipientSchema, // Who receives it + content: NotificationContentSchema, // What to send + scheduledFor: z.string().datetime().optional(), // When to send (optional, ISO 8601) + maxRetries: z.number().int().min(0).max(10).default(3), // Retry limit (0-10, default 3) + metadata: z.record(z.unknown()).optional(), // Additional tracking data }) .refine( (data) => { + // Cross-field validation: Ensure recipient has required contact info for channel // Email channel requires email address if (data.channel === NotificationChannel.EMAIL && !data.recipient.email) { return false; @@ -62,71 +166,216 @@ export const CreateNotificationDtoSchema = z export type CreateNotificationDto = z.infer; /** - * DTO for sending a notification immediately + * Schema for sending a notification immediately + * + * This is an alias of CreateNotificationDtoSchema. It has the exact same validation + * rules, but semantically indicates the notification will be sent immediately + * rather than just created. + * + * Use CreateNotificationDto when you want to create and potentially schedule for later. + * Use SendNotificationDto when you want to send immediately (scheduledFor will be ignored). */ export const SendNotificationDtoSchema = CreateNotificationDtoSchema; export type SendNotificationDto = z.infer; /** - * DTO for querying notifications + * Schema for querying/searching notifications + * + * This DTO supports flexible filtering and pagination for notification queries. + * All filter fields are optional - you can query by any combination. + * + * Filter fields: + * - recipientId: Find all notifications for a specific user + * - channel: Filter by channel (email, sms, push, etc.) + * - status: Filter by status (pending, sent, failed, etc.) + * - priority: Filter by priority level + * - fromDate: Find notifications created/sent after this date (ISO 8601) + * - toDate: Find notifications created/sent before this date (ISO 8601) + * + * Pagination fields: + * - limit: Maximum results to return (1-100, default: 10) + * - offset: Number of results to skip (default: 0) + * + * Example queries: + * ```json + * // Get user's failed emails (first 10) + * { "recipientId": "user-123", "channel": "email", "status": "failed", "limit": 10, "offset": 0 } + * + * // Get urgent notifications from last 24 hours + * { "priority": "urgent", "fromDate": "2026-03-30T00:00:00Z", "limit": 50 } + * + * // Get all SMS notifications (paginated) + * { "channel": "sms", "limit": 20, "offset": 40 } + * ``` */ export const QueryNotificationsDtoSchema = z.object({ - recipientId: z.string().optional(), - channel: z.nativeEnum(NotificationChannel).optional(), - status: z.string().optional(), - priority: z.nativeEnum(NotificationPriority).optional(), - fromDate: z.string().datetime().optional(), - toDate: z.string().datetime().optional(), - limit: z.number().int().positive().max(100).default(10), - offset: z.number().int().min(0).default(0), + recipientId: z.string().optional(), // Filter by recipient + channel: z.nativeEnum(NotificationChannel).optional(), // Filter by channel + status: z.string().optional(), // Filter by status + priority: z.nativeEnum(NotificationPriority).optional(), // Filter by priority + fromDate: z.string().datetime().optional(), // Filter by start date + toDate: z.string().datetime().optional(), // Filter by end date + limit: z.number().int().positive().max(100).default(10), // Page size (1-100) + offset: z.number().int().min(0).default(0), // Pagination offset }); export type QueryNotificationsDto = z.infer; /** - * DTO for updating notification status + * Schema for updating notification status + * + * This DTO is used for webhook callbacks and status updates from notification + * providers. When a provider sends a webhook (e.g., "message delivered"), we + * use this DTO to update the notification record. + * + * Fields: + * - notificationId: The ID of the notification to update + * - status: New status value (e.g., "delivered", "bounced", "failed") + * - error: Error message if status is failed/bounced + * - providerMessageId: Provider's tracking ID (Twilio SID, SendGrid ID, etc.) + * - metadata: Additional provider data (timestamps, costs, etc.) + * + * Example webhook payload from Twilio: + * ```json + * { + * "notificationId": "notif-123", + * "status": "delivered", + * "providerMessageId": "SM1234567890abcdef", + * "metadata": { + * "MessageStatus": "delivered", + * "MessageSid": "SM1234567890abcdef", + * "To": "+1234567890" + * } + * } + * ``` */ export const UpdateNotificationStatusDtoSchema = z.object({ - notificationId: z.string().min(1), - status: z.string().min(1), - error: z.string().optional(), - providerMessageId: z.string().optional(), - metadata: z.record(z.unknown()).optional(), + notificationId: z.string().min(1), // Required: notification ID + status: z.string().min(1), // Required: new status + error: z.string().optional(), // Optional: error message + providerMessageId: z.string().optional(), // Optional: provider tracking ID + metadata: z.record(z.unknown()).optional(), // Optional: provider data }); export type UpdateNotificationStatusDto = z.infer; /** - * DTO for bulk notification sending + * Schema for bulk notification sending + * + * This DTO allows sending the same notification to multiple recipients in one call. + * It's more efficient than making individual API calls for each recipient. + * + * Key differences from CreateNotificationDto: + * - Takes array of 'recipients' instead of single 'recipient' + * - Limited to 1000 recipients per bulk send (to prevent timeouts) + * - All recipients receive the same content + * - Channel must support bulk sending (most do) + * + * Use cases: + * - Marketing campaigns (send to list of customers) + * - Alerts/announcements (notify all users of maintenance) + * - Batch processing (process notification queue) + * + * Example: + * ```json + * { + * "channel": "email", + * "priority": "normal", + * "recipients": [ + * { "id": "user-1", "email": "user1@example.com" }, + * { "id": "user-2", "email": "user2@example.com" }, + * { "id": "user-3", "email": "user3@example.com" } + * ], + * "content": { + * "title": "Weekly Newsletter", + * "body": "Here's what's new this week..." + * }, + * "maxRetries": 3 + * } + * ``` + * + * Note: Each recipient will get a separate notification record in the database. */ export const BulkSendNotificationDtoSchema = z.object({ - channel: z.nativeEnum(NotificationChannel), - priority: z.nativeEnum(NotificationPriority).default(NotificationPriority.NORMAL), - recipients: z.array(NotificationRecipientSchema).min(1).max(1000), - content: NotificationContentSchema, - scheduledFor: z.string().datetime().optional(), - maxRetries: z.number().int().min(0).max(10).default(3), - metadata: z.record(z.unknown()).optional(), + channel: z.nativeEnum(NotificationChannel), // Which channel to send through + priority: z.nativeEnum(NotificationPriority).default(NotificationPriority.NORMAL), // Priority level + recipients: z.array(NotificationRecipientSchema).min(1).max(1000), // 1-1000 recipients + content: NotificationContentSchema, // What to send (same for all) + scheduledFor: z.string().datetime().optional(), // When to send (optional) + maxRetries: z.number().int().min(0).max(10).default(3), // Retry limit per notification + metadata: z.record(z.unknown()).optional(), // Tracking data (applied to all) }); export type BulkSendNotificationDto = z.infer; /** - * Validate and parse a DTO + * Validate and parse a DTO (throws on validation error) + * + * This function validates data against a Zod schema and returns the parsed + * (type-safe) result. If validation fails, it throws a ZodError with detailed + * error information. + * + * @param schema - The Zod schema to validate against + * @param data - The data to validate (typically from API request body) + * @returns The validated and parsed data (type-safe) + * @throws ZodError - If validation fails + * + * Usage: + * ```typescript + * try { + * const dto = validateDto(CreateNotificationDtoSchema, requestBody); + * // dto is now type-safe CreateNotificationDto + * await notificationService.create(dto); + * } catch (error) { + * if (error instanceof z.ZodError) { + * // Handle validation errors + * return { errors: error.errors }; + * } + * throw error; + * } + * ``` */ export function validateDto(schema: z.ZodSchema, data: unknown): T { - return schema.parse(data); + return schema.parse(data); // Throws ZodError if invalid } /** - * Safely validate a DTO and return result + * Safely validate a DTO without throwing (returns result object) + * + * This function validates data against a Zod schema but doesn't throw errors. + * Instead, it returns a result object indicating success or failure. + * + * @param schema - The Zod schema to validate against + * @param data - The data to validate + * @returns Success result { success: true, data: T } or failure result { success: false, errors: ZodError } + * + * This is useful when: + * - You want to handle validation errors without try/catch + * - You need to return validation errors to the caller + * - You're validating in middleware/interceptors + * + * Usage: + * ```typescript + * const result = validateDtoSafe(CreateNotificationDtoSchema, requestBody); + * if (result.success) { + * // result.data is type-safe CreateNotificationDto + * await notificationService.create(result.data); + * return { success: true }; + * } else { + * // result.errors contains detailed validation errors + * return { + * success: false, + * errors: result.errors.errors.map(e => ({ field: e.path.join('.'), message: e.message })) + * }; + * } + * ``` */ export function validateDtoSafe( schema: z.ZodSchema, data: unknown, ): { success: true; data: T } | { success: false; errors: z.ZodError } { - const result = schema.safeParse(data); + const result = schema.safeParse(data); // Doesn't throw if (result.success) { return { success: true, data: result.data }; } diff --git a/src/core/errors.ts b/src/core/errors.ts index 8e0f398..64e0561 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,5 +1,69 @@ /** - * Base error class for notification-related errors + * NotificationKit Custom Error Classes + * + * This file defines all custom error types used throughout the NotificationKit package. + * All errors extend from a base NotificationError class, which provides: + * - Consistent error structure (message, code, details) + * - Proper prototype chain for instanceof checks + * - Type-safe error handling + * + * Error Hierarchy: + * ``` + * Error (built-in) + * └── NotificationError (base class) + * ā”œā”€ā”€ ValidationError - Input validation failures + * ā”œā”€ā”€ NotificationNotFoundError - Notification doesn't exist + * ā”œā”€ā”€ SenderNotAvailableError - No sender configured for channel + * ā”œā”€ā”€ SendFailedError - Send operation failed + * ā”œā”€ā”€ InvalidRecipientError - Recipient data is invalid + * ā”œā”€ā”€ TemplateError - Template rendering failed + * └── MaxRetriesExceededError - Retry limit reached + * ``` + * + * Why custom errors? + * - Clear error identification: Each error type has a unique code + * - Structured details: Attach context data to errors (IDs, counts, etc.) + * - Better debugging: Stack traces and error names help troubleshoot + * - Type-safe catching: TypeScript can narrow error types in catch blocks + * - API consistency: Return standardized error responses to clients + * + * Usage: + * ```typescript + * try { + * await notificationService.send(dto); + * } catch (error) { + * if (error instanceof NotificationNotFoundError) { + * // Handle missing notification (404) + * return { status: 404, message: error.message, code: error.code }; + * } else if (error instanceof SendFailedError) { + * // Handle send failure (500) + * return { status: 500, message: error.message, details: error.details }; + * } + * // Handle other errors... + * } + * ``` + */ + +/** + * Base error class for all notification-related errors + * + * All NotificationKit errors extend from this class to provide a consistent + * error structure and proper instanceof checks. + * + * Properties: + * - message: Human-readable error description (inherited from Error) + * - _code: Machine-readable error code (e.g., "NOTIFICATION_NOT_FOUND") + * - _details: Additional context data (e.g., notificationId, retryCount) + * - name: Error class name (e.g., "NotificationError") + * - stack: Stack trace (inherited from Error) + * + * Why we use Object.setPrototypeOf? + * TypeScript transpiles to ES5 by default, which breaks instanceof checks for + * custom errors. Setting the prototype explicitly fixes this. + * + * @param message - Description of what went wrong + * @param _code - Unique error code for programmatic handling + * @param _details - Additional structured data about the error */ export class NotificationError extends Error { constructor( @@ -7,22 +71,49 @@ export class NotificationError extends Error { public readonly _code: string, public readonly _details?: Record, ) { - super(message); - this.name = "NotificationError"; - Object.setPrototypeOf(this, NotificationError.prototype); + super(message); // Call parent Error constructor + this.name = "NotificationError"; // Set error name for stack traces + Object.setPrototypeOf(this, NotificationError.prototype); // Fix prototype chain for instanceof } + // Getter for error code (makes it readonly to consumers) get code(): string { return this._code; } + // Getter for error details (makes it readonly to consumers) get details(): Record | undefined { return this._details; } } /** - * Error thrown when validation fails + * Validation error - thrown when input data fails validation + * + * This error is thrown when: + * - DTO validation fails (Zod schema validation) + * - Business rule validation fails (e.g., invalid date range) + * - Required fields are missing + * - Field values are out of acceptable range + * + * Error Code: "VALIDATION_ERROR" + * HTTP Status: 400 Bad Request + * + * Example usage: + * ```typescript + * if (!email || !email.includes('@')) { + * throw new ValidationError("Invalid email format", { email }); + * } + * ``` + * + * Example error details: + * ```json + * { + * "message": "Recipient email is required for EMAIL channel", + * "code": "VALIDATION_ERROR", + * "details": { "channel": "email", "recipient": { "id": "user-123" } } + * } + * ``` */ export class ValidationError extends NotificationError { constructor(message: string, details?: Record) { @@ -33,7 +124,32 @@ export class ValidationError extends NotificationError { } /** - * Error thrown when a notification is not found + * Notification not found error - thrown when looking up a notification that doesn't exist + * + * This error is thrown when: + * - Querying by ID and notification doesn't exist in database + * - Attempting to update/delete a non-existent notification + * - Trying to retry a notification that was already deleted + * + * Error Code: "NOTIFICATION_NOT_FOUND" + * HTTP Status: 404 Not Found + * + * Example usage: + * ```typescript + * const notification = await repository.findById(id); + * if (!notification) { + * throw new NotificationNotFoundError(id); + * } + * ``` + * + * Example error: + * ```json + * { + * "message": "Notification with ID notif-123 not found", + * "code": "NOTIFICATION_NOT_FOUND", + * "details": { "notificationId": "notif-123" } + * } + * ``` */ export class NotificationNotFoundError extends NotificationError { constructor(notificationId: string) { @@ -46,7 +162,37 @@ export class NotificationNotFoundError extends NotificationError { } /** - * Error thrown when a sender is not available for a channel + * Sender not available error - thrown when no sender is configured for a channel + * + * This error is thrown when: + * - No sender is registered for the requested channel + * - Sender exists but isReady() returns false (not configured/connected) + * - Sender was not provided during module initialization + * + * Error Code: "SENDER_NOT_AVAILABLE" + * HTTP Status: 503 Service Unavailable + * + * Common causes: + * - Forgot to register sender: NotificationKitModule.forRoot({ senders: [emailSender] }) + * - Missing credentials: EMAIL_USER and EMAIL_PASS not set + * - Connection failure: SMTP server unreachable + * + * Example usage: + * ```typescript + * const sender = this.senders.get(channel); + * if (!sender || !(await sender.isReady())) { + * throw new SenderNotAvailableError(channel); + * } + * ``` + * + * Example error: + * ```json + * { + * "message": "No sender available for channel: sms", + * "code": "SENDER_NOT_AVAILABLE", + * "details": { "channel": "sms" } + * } + * ``` */ export class SenderNotAvailableError extends NotificationError { constructor(channel: string) { @@ -57,7 +203,43 @@ export class SenderNotAvailableError extends NotificationError { } /** - * Error thrown when sending a notification fails + * Send failed error - thrown when notification send operation fails + * + * This error is thrown when: + * - Provider API returns an error (Twilio, SendGrid, Firebase, etc.) + * - Network request to provider fails + * - Provider rate limit exceeded + * - Invalid credentials or authentication failure + * + * Error Code: "SEND_FAILED" + * HTTP Status: 500 Internal Server Error (or 502 Bad Gateway) + * + * This error includes details from the provider's error response. + * The notification will be marked as FAILED and can be retried. + * + * Example usage: + * ```typescript + * const result = await sender.send(recipient, content); + * if (!result.success) { + * throw new SendFailedError(result.error || "Send failed", { + * notificationId: notification.id, + * providerError: result.error + * }); + * } + * ``` + * + * Example error: + * ```json + * { + * "message": "Twilio error: Invalid 'To' Phone Number", + * "code": "SEND_FAILED", + * "details": { + * "notificationId": "notif-456", + * "providerError": "21211: Invalid To Phone Number", + * "providerCode": 21211 + * } + * } + * ``` */ export class SendFailedError extends NotificationError { constructor(message: string, details?: Record) { @@ -68,7 +250,39 @@ export class SendFailedError extends NotificationError { } /** - * Error thrown when recipient validation fails + * Invalid recipient error - thrown when recipient data is invalid for the channel + * + * This error is thrown when: + * - EMAIL channel but no email address provided + * - SMS channel but no phone number provided + * - PUSH channel but no device token provided + * - Email address format is invalid + * - Phone number format is invalid + * + * Error Code: "INVALID_RECIPIENT" + * HTTP Status: 400 Bad Request + * + * Example usage: + * ```typescript + * if (channel === 'email' && !recipient.email) { + * throw new InvalidRecipientError( + * "Email address is required for EMAIL channel", + * { channel, recipient } + * ); + * } + * ``` + * + * Example error: + * ```json + * { + * "message": "Phone number is required for SMS channel", + * "code": "INVALID_RECIPIENT", + * "details": { + * "channel": "sms", + * "recipient": { "id": "user-789" } + * } + * } + * ``` */ export class InvalidRecipientError extends NotificationError { constructor(message: string, details?: Record) { @@ -79,7 +293,40 @@ export class InvalidRecipientError extends NotificationError { } /** - * Error thrown when template rendering fails + * Template error - thrown when template rendering fails + * + * This error is thrown when: + * - Template with specified ID doesn't exist + * - Template has syntax errors (invalid Handlebars/Mustache syntax) + * - Required template variables are missing + * - Template engine is not configured but templateId was provided + * + * Error Code: "TEMPLATE_ERROR" + * HTTP Status: 500 Internal Server Error + * + * Example usage: + * ```typescript + * try { + * const rendered = await templateEngine.render(templateId, vars); + * } catch (error) { + * throw new TemplateError(`Failed to render template: ${templateId}`, { + * templateId, + * error: error.message + * }); + * } + * ``` + * + * Example error: + * ```json + * { + * "message": "Failed to render template: welcome-email", + * "code": "TEMPLATE_ERROR", + * "details": { + * "templateId": "welcome-email", + * "error": "Missing required variable: userName" + * } + * } + * ``` */ export class TemplateError extends NotificationError { constructor(message: string, details?: Record) { @@ -90,7 +337,38 @@ export class TemplateError extends NotificationError { } /** - * Error thrown when notification has reached max retries + * Max retries exceeded error - thrown when notification has been retried too many times + * + * This error is thrown when: + * - Notification.retryCount >= Notification.maxRetries + * - Attempting to retry a notification that's already exceeded the limit + * + * Error Code: "MAX_RETRIES_EXCEEDED" + * HTTP Status: 400 Bad Request (if manual retry) or 500 (if auto-retry) + * + * When this error is thrown: + * - Notification status remains FAILED + * - No further retry attempts will be made + * - Manual intervention required (fix issue, increase maxRetries, or recreate notification) + * + * Example usage: + * ```typescript + * if (notification.retryCount >= notification.maxRetries) { + * throw new MaxRetriesExceededError(notification.id, notification.retryCount); + * } + * ``` + * + * Example error: + * ```json + * { + * "message": "Notification notif-999 exceeded max retries (3)", + * "code": "MAX_RETRIES_EXCEEDED", + * "details": { + * "notificationId": "notif-999", + * "retryCount": 3 + * } + * } + * ``` */ export class MaxRetriesExceededError extends NotificationError { constructor(notificationId: string, retryCount: number) { diff --git a/src/core/index.ts b/src/core/index.ts index cf1dbac..918a934 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,17 +1,30 @@ -// Public exports from core go here. -// Keep core framework-free (no Nest imports). +/** + * @file Core Layer Export Index + * + * This file manages all exports from the core domain layer of NotificationKit. + * The core layer is completely framework-agnostic and contains: + * + * - Types & Enums: Domain types, enums for channels, status, and priority + * - DTOs: Data Transfer Objects for API operations (validation with Zod) + * - Ports: Interface abstractions for senders, repositories, and services + * - Errors: Custom error classes for domain-specific exceptions + * - Services: Core business logic for managing notifications + * + * Design Principle: This layer has zero framework dependencies (no NestJS, no Express, etc.) + * This ensures the business logic can be reused in any context. + */ -// Types and enums +// Types and enums - Core domain models and enumeration values export * from "./types"; -// DTOs and schemas +// DTOs and schemas - Data Transfer Objects with Zod validation export * from "./dtos"; -// Port interfaces (abstractions) +// Port interfaces (abstractions) - Contracts that infrastructure must implement export * from "./ports"; -// Errors +// Errors - Domain-specific custom exception classes export * from "./errors"; -// Core service +// Core service - Main notification business logic export * from "./notification.service"; diff --git a/src/core/notification.service.ts b/src/core/notification.service.ts index 9d62e34..6ac9cc5 100644 --- a/src/core/notification.service.ts +++ b/src/core/notification.service.ts @@ -1,3 +1,31 @@ +/** + * @file Core Notification Service + * + * This file contains the main NotificationService class which orchestrates all notification operations. + * It is the heart of the NotificationKit business logic and handles: + * + * - Creating notifications (with validation and template rendering) + * - Sending notifications through appropriate channels + * - Managing notification lifecycle (pending, queued, sending, sent, delivered, failed) + * - Handling retries for failed notifications + * - Processing scheduled notifications + * - Querying and managing notification records + * - Emitting lifecycle events for monitoring + * + * Architecture: + * - This service depends only on port interfaces (INotificationSender, INotificationRepository, etc.) + * - It knows nothing about specific implementations (Nodemailer, Twilio, MongoDB, etc.) + * - This allows swapping implementations without changing this code (dependency inversion) + * + * Key Responsibilities: + * 1. Validation: Ensures recipients have required fields for their channel + * 2. Template Processing: Renders templates if templateId is provided + * 3. Scheduling: Handles scheduled vs immediate sends + * 4. Retry Logic: Attempts retries on failures (respecting maxRetries) + * 5. Status Tracking: Updates notification status throughout lifecycle + * 6. Event Emission: Fires events for monitoring and integrations + */ + import type { CreateNotificationDto, SendNotificationDto } from "./dtos"; import { InvalidRecipientError, @@ -19,11 +47,29 @@ import type { import { type Notification, type NotificationResult, NotificationStatus } from "./types"; /** - * Core notification service - contains all business logic + * Core notification service - orchestrates all notification business logic + * + * This service is the main entry point for notification operations and coordinates + * between senders, repository, template engine, and other dependencies. */ export class NotificationService { + // Map of notification channels to their respective senders (e.g., "email" -> NodemailerSender) + // This allows O(1) lookup when routing notifications to the correct sender private readonly senders: Map; + /** + * Constructor - Initialize the notification service with all dependencies + * + * @param repository - Repository for persisting notifications (MongoDB, PostgreSQL, etc.) + * @param idGenerator - Generator for creating unique notification IDs + * @param dateTimeProvider - Provider for working with dates/times (for scheduling) + * @param senders - Array of notification senders (email, SMS, push, etc.) + * @param templateEngine - Optional template engine for rendering notification content + * @param eventEmitter - Optional event emitter for lifecycle events + * + * The senders array is converted to a Map keyed by channel for fast lookups. + * Example: [NodemailerSender, TwilioSmsSender] becomes Map { "email" => NodemailerSender, "sms" => TwilioSmsSender } + */ constructor( private readonly repository: INotificationRepository, private readonly idGenerator: IIdGenerator, @@ -32,14 +78,32 @@ export class NotificationService { private readonly templateEngine?: ITemplateEngine, private readonly eventEmitter?: INotificationEventEmitter, ) { + // Build a map of channel name to sender for efficient routing + // This allows us to quickly find the right sender based on notification channel this.senders = new Map(senders.map((sender) => [sender.channel, sender])); } /** - * Create a new notification without sending it + * Create a new notification without sending it immediately + * + * This method: + * 1. Validates the recipient has required fields for the channel + * 2. Renders template if templateId is provided + * 3. Determines initial status (PENDING for scheduled, QUEUED for immediate) + * 4. Persists the notification to repository + * 5. Emits a "notification.created" event + * + * @param dto - Data transfer object containing notification details + * @returns Promise - The created notification with generated ID and timestamps + * @throws InvalidRecipientError - If recipient is missing required fields for the channel + * @throws TemplateError - If template rendering fails + * + * Use this method when you want to create a notification record but send it later + * (e.g., for batch processing, scheduled sends, or queue-based systems) */ async create(dto: CreateNotificationDto): Promise { - // Validate recipient for the channel + // Step 1: Validate recipient has required fields for the channel + // Example: Email channel requires email address, SMS requires phone number const sender = this.senders.get(dto.channel); if (sender && !sender.validateRecipient(dto.recipient)) { throw new InvalidRecipientError( @@ -48,14 +112,20 @@ export class NotificationService { ); } - // Process template if provided + // Step 2: Process template if template ID is provided + // This replaces {{variables}} in the template with actual values let content = dto.content; if (dto.content.templateId && this.templateEngine) { content = await this.renderTemplate(dto); } + // Step 3: Determine initial status based on scheduling + // If scheduledFor is in the future, status = PENDING (waiting for scheduled time) + // Otherwise, status = QUEUED (ready to send immediately) const isScheduled = dto.scheduledFor && this.dateTimeProvider.isFuture(dto.scheduledFor); + // Step 4: Persist the notification to the database + // The repository will generate ID, createdAt, and updatedAt timestamps const notification = await this.repository.create({ channel: dto.channel, status: isScheduled ? NotificationStatus.PENDING : NotificationStatus.QUEUED, @@ -71,6 +141,7 @@ export class NotificationService { metadata: dto.metadata, }); + // Step 5: Emit creation event for monitoring/logging await this.emitEvent({ type: "notification.created", notification }); return notification; @@ -78,32 +149,75 @@ export class NotificationService { /** * Send a notification immediately + * + * This is a convenience method that combines create() and send(): + * 1. Creates the notification record + * 2. Immediately sends it through the appropriate channel + * + * @param dto - Notification details (channel, recipient, content, etc.) + * @returns Promise - Result with success status and provider details + * @throws InvalidRecipientError - If recipient is invalid + * @throws SenderNotAvailableError - If no sender for channel or sender not ready + * @throws SendFailedError - If send operation fails + * + * This is the most common method - use it when you want to send right away */ async send(dto: SendNotificationDto): Promise { - // Create the notification + // Create the notification record in database const notification = await this.create(dto); - // Send it immediately + // Send it immediately through the appropriate channel sender return this.sendNotification(notification); } /** * Send an existing notification by ID + * + * Useful for: + * - Retrying failed notifications + * - Sending scheduled notifications when their time arrives + * - Processing notifications from a queue + * + * @param notificationId - The ID of the notification to send + * @returns Promise - Result of the send operation + * @throws NotificationNotFoundError - If notification doesn't exist + * @throws SenderNotAvailableError - If sender not available */ async sendById(notificationId: string): Promise { + // Retrieve the notification from database const notification = await this.repository.findById(notificationId); if (!notification) { throw new NotificationNotFoundError(notificationId); } + // Send it through the channel sender return this.sendNotification(notification); } /** - * Internal method to send a notification + * Internal method to send a notification through its channel + * + * This is the core send logic that: + * 1. Validates notification can be sent (not already sent/cancelled) + * 2. Checks retry limits haven't been exceeded + * 3. Finds and validates the appropriate sender + * 4. Updates status to SENDING + * 5. Calls the sender to perform actual send + * 6. Updates status to SENT on success or FAILED on error + * 7. Emits lifecycle events throughout the process + * 8. Handles retries by incrementing retry count + * + * @param notification - The notification to send + * @returns Promise - Result with success status and details + * @throws MaxRetriesExceededError - If retry limit reached + * @throws SenderNotAvailableError - If no sender for channel or not ready + * @throws SendFailedError - If send fails + * + * This method is called internally by send(), sendById(), and retry() */ private async sendNotification(notification: Notification): Promise { - // Check if notification can be sent + // Guard: Check if notification is already sent (idempotency check) + // Prevents duplicate sends if method is called twice if (notification.status === NotificationStatus.SENT) { return { success: true, @@ -112,6 +226,8 @@ export class NotificationService { }; } + // Guard: Check if notification was cancelled + // Cancelled notifications should not be sent if (notification.status === NotificationStatus.CANCELLED) { return { success: false, @@ -120,46 +236,54 @@ export class NotificationService { }; } - // Check retry limit + // Guard: Check if retry limit has been exceeded + // Prevents infinite retry loops if (notification.retryCount >= notification.maxRetries) { throw new MaxRetriesExceededError(notification.id, notification.retryCount); } - // Get sender for channel + // Get the sender for this notification's channel + // Example: For channel="email", get NodemailerSender from the senders map const sender = this.senders.get(notification.channel); if (!sender) { throw new SenderNotAvailableError(notification.channel); } - // Check sender is ready + // Check if sender is properly configured and ready to send + // This verifies credentials, connections, etc. const isReady = await sender.isReady(); if (!isReady) { throw new SenderNotAvailableError(notification.channel); } try { - // Update status to sending + // Step 1: Update status to SENDING in database + // This marks the notification as currently being processed await this.repository.update(notification.id, { status: NotificationStatus.SENDING, updatedAt: this.dateTimeProvider.now(), }); + // Emit event for monitoring/logging await this.emitEvent({ type: "notification.sending", notification: { ...notification, status: NotificationStatus.SENDING }, }); - // Send the notification + // Step 2: Perform the actual send operation through the sender + // This calls the external provider (Twilio, Nodemailer, Firebase, etc.) const result = await sender.send(notification.recipient, notification.content); if (result.success) { - // Update status to sent + // SUCCESS PATH: Send was successful + // Update notification record with sent status and timestamp const updatedNotification = await this.repository.update(notification.id, { status: NotificationStatus.SENT, sentAt: this.dateTimeProvider.now(), updatedAt: this.dateTimeProvider.now(), }); + // Emit success event for monitoring await this.emitEvent({ type: "notification.sent", notification: updatedNotification, @@ -168,7 +292,9 @@ export class NotificationService { return result; } else { - // Sending failed, update retry count + // FAILURE PATH: Send failed (provider returned failure) + // Update notification with failed status and error message + // Increment retry count for potential future retry attempts const updatedNotification = await this.repository.update(notification.id, { status: NotificationStatus.FAILED, error: result.error, @@ -176,21 +302,25 @@ export class NotificationService { updatedAt: this.dateTimeProvider.now(), }); + // Emit failure event for monitoring/alerts await this.emitEvent({ type: "notification.failed", notification: updatedNotification, error: result.error || "Unknown error", }); + // Throw error to propagate failure to caller throw new SendFailedError(result.error || "Send failed", { notificationId: notification.id, result, }); } } catch (error) { - // Handle unexpected errors + // EXCEPTION PATH: Unexpected error occurred during send + // This catches errors from the sender or other unexpected issues const errorMessage = error instanceof Error ? error.message : "Unknown error"; + // Update notification status to FAILED and record error await this.repository.update(notification.id, { status: NotificationStatus.FAILED, error: errorMessage, @@ -198,12 +328,22 @@ export class NotificationService { updatedAt: this.dateTimeProvider.now(), }); + // Re-throw error for caller to handle throw error; } } /** - * Get a notification by ID + * Get a notification by its unique ID + * + * @param notificationId - The notification ID to retrieve + * @returns Promise - The notification entity + * @throws NotificationNotFoundError - If notification doesn't exist + * + * Use this to: + * - Check notification status + * - Display notification details to users + * - Verify notification was created */ async getById(notificationId: string): Promise { const notification = await this.repository.findById(notificationId); @@ -214,39 +354,71 @@ export class NotificationService { } /** - * Query notifications + * Query/search for notifications matching criteria + * + * @param criteria - Query filters (recipientId, channel, status, dates, pagination) + * @returns Promise - Array of matching notifications + * + * Examples: + * - Get all notifications for a user: { recipientId: "user-123" } + * - Get failed emails: { channel: "email", status: "failed" } + * - Get recent urgent notifications: { priority: "urgent", fromDate: "2026-03-01T00:00:00Z", limit: 10 } */ async query(criteria: NotificationQueryCriteria): Promise { return this.repository.find(criteria); } /** - * Count notifications + * Count notifications matching criteria + * + * @param criteria - Query filters (same as query method) + * @returns Promise - Count of matching notifications + * + * Useful for: + * - Dashboard statistics (total sent, failed, etc.) + * - Pagination (showing "Page 1 of 10") + * - Monitoring alerts (e.g., "100 failed notifications in last hour") */ async count(criteria: NotificationQueryCriteria): Promise { return this.repository.count(criteria); } /** - * Cancel a notification + * Cancel a pending or queued notification + * + * @param notificationId - ID of notification to cancel + * @returns Promise - The cancelled notification + * @throws NotificationNotFoundError - If notification doesn't exist + * @throws SendFailedError - If notification already sent (can't cancel sent notifications) + * + * Use cases: + * - User opts out before scheduled notification sends + * - Business logic changes and notification is no longer relevant + * - Testing/debugging + * + * Note: You cannot cancel a notification that's already been sent */ async cancel(notificationId: string): Promise { + // Retrieve the notification const notification = await this.repository.findById(notificationId); if (!notification) { throw new NotificationNotFoundError(notificationId); } + // Check if already sent (can't cancel sent notifications) if (notification.status === NotificationStatus.SENT) { throw new SendFailedError("Cannot cancel a notification that has already been sent", { notificationId, }); } + // Update status to cancelled const updated = await this.repository.update(notificationId, { status: NotificationStatus.CANCELLED, updatedAt: this.dateTimeProvider.now(), }); + // Emit cancellation event await this.emitEvent({ type: "notification.cancelled", notification: updated }); return updated; @@ -254,6 +426,22 @@ export class NotificationService { /** * Retry a failed notification + * + * @param notificationId - ID of the failed notification to retry + * @returns Promise - Result of the retry attempt + * @throws NotificationNotFoundError - If notification doesn't exist + * @throws SendFailedError - If notification isn't in FAILED status + * @throws MaxRetriesExceededError - If retry limit already reached + * + * This method: + * 1. Retrieves the notification + * 2. Validates it's in FAILED status + * 3. Attempts to send it again (retry count will be incremented) + * + * Use for: + * - Manual retry buttons in admin UI + * - Automated retry jobs (e.g., retry all failed from last hour) + * - Scheduled retry attempts with exponential backoff */ async retry(notificationId: string): Promise { const notification = await this.repository.findById(notificationId); @@ -261,26 +449,53 @@ export class NotificationService { throw new NotificationNotFoundError(notificationId); } + // Only failed notifications can be retried if (notification.status !== NotificationStatus.FAILED) { throw new SendFailedError("Only failed notifications can be retried", { notificationId }); } + // Attempt to send again (retry count will be checked and incremented) return this.sendNotification(notification); } /** * Process scheduled notifications that are ready to send + * + * This method is designed to be called by a scheduled job (cron, queue worker, etc.) + * It: + * 1. Queries for notifications ready to send (QUEUED or PENDING with past scheduledFor) + * 2. Sends each notification + * 3. Continues processing even if individual sends fail + * 4. Returns results for all processed notifications + * + * @param limit - Maximum number of notifications to process (default: 100) + * @returns Promise - Results for all processed notifications + * + * Example cron job setup: + * ``` + * // Every minute, process up to 100 scheduled notifications + * schedule.scheduleJob('* * * * *', async () => { + * const results = await notificationService.processScheduled(100); + * console.log(`Processed ${results.length} notifications`); + * }); + * ``` */ async processScheduled(limit = 100): Promise { + // Get notifications that are ready to send + // This includes QUEUED and PENDING notifications where scheduledFor <= now const notifications = await this.repository.findReadyToSend(limit); const results: NotificationResult[] = []; + // Process each notification + // Note: We don't stop on errors - we want to process as many as possible for (const notification of notifications) { try { + // Try to send the notification const result = await this.sendNotification(notification); results.push(result); } catch (error) { - // Log error but continue processing other notifications + // If send fails, record the error but continue processing others + // This ensures one bad notification doesn't block the entire batch results.push({ success: false, notificationId: notification.id, @@ -294,6 +509,32 @@ export class NotificationService { /** * Mark a notification as delivered (called by webhook/callback) + * + * Many notification providers (Twilio, SendGrid, etc.) send webhook callbacks + * when messages are delivered. This method handles those callbacks. + * + * @param notificationId - ID of the notification that was delivered + * @param metadata - Optional additional metadata from the provider + * @returns Promise - The updated notification + * @throws NotificationNotFoundError - If notification doesn't exist + * + * Status flow: + * SENT -> DELIVERED (confirmed by provider that recipient received it) + * + * This is useful for: + * - Tracking delivery rates + * - Confirming message receipt + * - Debugging delivery issues + * + * Example webhook handler: + * ``` + * @Post('webhooks/twilio') + * async twilioWebhook(@Body() body) { + * if (body.MessageStatus === 'delivered') { + * await this.notificationService.markAsDelivered(body.MessageSid, body); + * } + * } + * ``` */ async markAsDelivered( notificationId: string, @@ -304,39 +545,72 @@ export class NotificationService { throw new NotificationNotFoundError(notificationId); } + // Update the notification status to DELIVERED + // Merge any webhook metadata with existing metadata const updated = await this.repository.update(notificationId, { status: NotificationStatus.DELIVERED, deliveredAt: this.dateTimeProvider.now(), updatedAt: this.dateTimeProvider.now(), - metadata: { ...notification.metadata, ...metadata }, + metadata: { ...notification.metadata, ...metadata }, // Preserve existing + add new }); + // Emit delivery event for analytics/monitoring await this.emitEvent({ type: "notification.delivered", notification: updated }); return updated; } /** - * Render a template with variables + * Render a template with variables (private helper) + * + * This method handles template rendering if a template engine is configured. + * It takes a template ID and variables, renders the template, and returns + * the rendered content (title, body, html). + * + * @param dto - The create notification DTO with template info + * @returns Promise - Content with rendered template + * @throws TemplateError - If template rendering fails + * + * Template rendering flow: + * 1. Check if template engine is available and templateId is provided + * 2. If no template, return original content as-is + * 3. Call template engine with templateId and variables + * 4. Merge rendered content (title, body, html) back into content object + * + * Example template: + * ``` + * Template ID: "welcome-email" + * Template: "Hello {{name}}, welcome to {{appName}}!" + * Variables: { name: "John", appName: "MyApp" } + * Result: "Hello John, welcome to MyApp!" + * ``` */ private async renderTemplate(dto: CreateNotificationDto) { + // Guard: If no template engine or no templateId, return content as-is + // This allows non-template notifications to work without a template engine if (!this.templateEngine || !dto.content.templateId) { return dto.content; } try { + // Render the template using the template engine + // Pass templateId (e.g., "welcome-email") and variables (e.g., { name: "John" }) const rendered = await this.templateEngine.render( dto.content.templateId, - dto.content.templateVars || {}, + dto.content.templateVars || {}, // Use empty object if no variables provided ); + // Merge rendered content back into the content object + // This preserves any other content properties while replacing title/body/html return { ...dto.content, - title: rendered.title, - body: rendered.body, - html: rendered.html, + title: rendered.title, // Rendered template title + body: rendered.body, // Rendered template body (plain text) + html: rendered.html, // Rendered template HTML (if applicable) }; } catch (error) { + // If template rendering fails, throw a descriptive error + // This could happen if template doesn't exist or has syntax errors throw new TemplateError(`Failed to render template: ${dto.content.templateId}`, { templateId: dto.content.templateId, error: error instanceof Error ? error.message : "Unknown error", @@ -345,7 +619,29 @@ export class NotificationService { } /** - * Emit a notification event + * Emit a notification event (private helper) + * + * This method publishes lifecycle events to the event emitter (if configured). + * Events are used for: + * - Monitoring and logging (track all notification state changes) + * - Analytics (count sent, failed, delivered notifications) + * - Integrations (trigger workflows, webhooks, alerts) + * - Auditing (compliance, tracking) + * + * @param event - The event to emit (type + data) + * + * Event types: + * - "notification.created" - When notification is created + * - "notification.sending" - When send starts + * - "notification.sent" - When send succeeds + * - "notification.failed" - When send fails + * - "notification.delivered" - When provider confirms delivery + * - "notification.cancelled" - When notification is cancelled + * + * Implementation note: + * - If no eventEmitter is configured, this is a no-op (silently skip) + * - Event emission is async but we await it to ensure proper sequencing + * - Events should not throw errors (emitter should handle failures gracefully) */ private async emitEvent(event: Parameters[0]): Promise { if (this.eventEmitter) { diff --git a/src/core/ports.ts b/src/core/ports.ts index cad6f1a..9337dcf 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -1,3 +1,29 @@ +/** + * @file Port Interfaces (Hexagonal Architecture) + * + * This file defines all the "port" interfaces used in the NotificationKit system. + * Following hexagonal/clean architecture principles, these are the contracts + * that the infrastructure layer must implement. + * + * What are Ports? + * - Ports are interfaces that define HOW the core business logic * (domain layer) communicates with external systems. + * - They allow the core to remain independent of specific implementations. + * - The infrastructure layer provides "adapters" that implement these ports. + * + * Port Interfaces Defined: + * - INotificationSender: Contract for sending notifications through various channels + * - INotificationRepository: Contract for persisting and retrieving notifications + * - ITemplateEngine: Contract for rendering notification templates + * - IIdGenerator: Contract for generating unique notification IDs + * - IDateTimeProvider: Contract for working with dates/times + * - INotificationEventEmitter: Contract for emitting notification lifecycle events + * + * Benefits: + * - Testability: Easy to mock these interfaces in tests + * - Flexibility: Swap implementations without changing core logic + * - Framework independence: Core layer doesn't depend on specific libraries + */ + import type { Notification, NotificationChannel, @@ -8,16 +34,34 @@ import type { /** * Port: Notification sender abstraction - * Infrastructure layer will implement this for each channel (email, SMS, etc.) + * + * This interface defines the contract that all notification senders must implement. + * The infrastructure layer will provide concrete implementations for each channel: + * - Email: NodemailerSender, AwsSesSender, etc. + * - SMS: TwilioSmsSender, AwsSnsSender, VonageSmsSender, etc. + * - Push: FirebasePushSender, OneSignalPushSender, etc. + * + * Each sender is responsible for: + * 1. Sending messages through their specific provider/channel + * 2. Validating recipient information matches channel requirements + * 3. Checking readiness/configuration before attempting to send */ export interface INotificationSender { /** - * The channel this sender handles + * The channel this sender handles (e.g., "email", "sms", "push") + * Used by the core service to route notifications to the correct sender */ readonly channel: NotificationChannel; /** * Send a notification to a recipient + * + * @param _recipient - The recipient information (email, phone, device token, etc.) + * @param _content - The notification content (title, body, html, etc.) + * @returns Promise - Result indicating success/failure and provider details + * + * This method performs the actual sending operation through the provider's API. + * It should handle provider-specific errors and return a consistent result format. */ send( _recipient: NotificationRecipient, @@ -26,169 +70,522 @@ export interface INotificationSender { /** * Check if the sender is properly configured and ready + * + * @returns Promise - true if ready to send, false otherwise + * + * This method verifies: + * - Required configuration/credentials are present + * - Connection to provider can be established + * - Any required resources are available + * + * Called before attempting to send to avoid unnecessary failures */ isReady(): Promise; /** * Validate recipient has required fields for this channel + * + * @param _recipient - The recipient to validate + * @returns boolean - true if recipient is valid for this channel + * + * Validation rules by channel: + * - Email: Must have valid email address + * - SMS: Must have valid phone number + * - Push: Must have device token + * - In-App: Must have user ID + * - Webhook: Must have URL in metadata */ validateRecipient(_recipient: NotificationRecipient): boolean; } /** * Port: Notification repository abstraction - * Infrastructure layer will implement this for persistence + * + * This interface defines the contract for persisting and retrieving notifications. + * The infrastructure layer will provide implementations for different databases: + * - MongoDB: MongooseRepository + * - PostgreSQL: PostgresRepository (custom) + * - In-Memory: InMemoryRepository (for testing) + * + * The repository handles all CRUD operations and queries for notifications, + * allowing the core service to remain database-agnostic. */ export interface INotificationRepository { /** - * Create a new notification record + * Create a new notification record in the database + * + * @param _notification - Notification data (without id, createdAt, updatedAt) + * @returns Promise - The created notification with generated ID and timestamps + * + * The repository implementation should: + * - Generate a unique ID + * - Set createdAt and updatedAt timestamps + * - Persist to database + * - Return the complete notification object */ create( _notification: Omit, ): Promise; /** - * Find a notification by ID + * Find a notification by its unique ID + * + * @param _id - The notification ID to search for + * @returns Promise - The notification if found, null otherwise + * + * Used for: + * - Retrieving notification details + * - Checking notification status + * - Retry operations */ findById(_id: string): Promise; /** - * Find notifications matching criteria + * Find notifications matching specified criteria + * + * @param _criteria - Query filters (recipientId, channel, status, dates, pagination) + * @returns Promise - Array of matching notifications + * + * Supports filtering by: + * - Recipient ID (get all notifications for a user) + * - Channel (get all email/SMS/push notifications) + * - Status (find pending/failed/sent notifications) + * - Priority (get urgent notifications) + * - Date range (notifications between dates) + * - Pagination (limit and offset) */ find(_criteria: NotificationQueryCriteria): Promise; /** - * Update a notification + * Update an existing notification + * + * @param _id - The notification ID to update + * @param _updates - Partial notification data to update + * @returns Promise - The updated notification + * + * Used for: + * - Updating status during send process + * - Recording send/delivery timestamps + * - Incrementing retry count + * - Storing error messages */ update(_id: string, _updates: Partial): Promise; /** - * Delete a notification + * Delete a notification by ID + * + * @param _id - The notification ID to delete + * @returns Promise - true if deleted, false if not found + * + * Note: Be cautious with deletion - consider soft deletes for audit purposes */ delete(_id: string): Promise; /** * Count notifications matching criteria + * + * @param _criteria - Query filters (same as find) + * @returns Promise - Count of matching notifications + * + * Useful for: + * - Dashboard statistics + * - Pagination (total count) + * - Monitoring failed notification counts */ count(_criteria: NotificationQueryCriteria): Promise; /** * Find notifications that are ready to be sent + * + * @param _limit - Maximum number of notifications to return + * @returns Promise - Notifications ready for sending + * + * Returns notifications that are: + * - Status = QUEUED (ready to send immediately), OR + * - Status = PENDING with scheduledFor <= now (scheduled time has arrived) + * + * Ordered by: + * 1. Priority (urgent first) + * 2. Created date (oldest first) + * + * Used by scheduled jobs or queue processors to batch-send notifications */ findReadyToSend(_limit: number): Promise; } /** * Query criteria for finding notifications + * + * This interface defines all possible filters for querying notifications. + * All fields are optional - you can combine any filters you need. + * + * Example queries: + * ```typescript + * // Find all failed emails for a user + * { recipientId: "user-123", channel: "email", status: "failed" } + * + * // Find urgent notifications from last 24 hours + * { priority: "urgent", fromDate: "2026-03-30T00:00:00Z", limit: 50 } + * + * // Paginate through SMS notifications (page 3, 20 per page) + * { channel: "sms", limit: 20, offset: 40 } + * ``` */ export interface NotificationQueryCriteria { - recipientId?: string; - channel?: NotificationChannel; - status?: string; - priority?: string; - fromDate?: string; - toDate?: string; - limit?: number; - offset?: number; + recipientId?: string; // Filter by recipient ID + channel?: NotificationChannel; // Filter by channel (email, sms, push, etc.) + status?: string; // Filter by status (pending, sent, failed, etc.) + priority?: string; // Filter by priority (low, normal, high, urgent) + fromDate?: string; // Start date (ISO 8601) + toDate?: string; // End date (ISO 8601) + limit?: number; // Max results to return (pagination) + offset?: number; // Number of results to skip (pagination) } /** - * Port: Template engine abstraction - * Infrastructure layer will implement this for template rendering + * Port: Template Engine - Abstraction for rendering notification templates + * + * The template engine is responsible for: + * - Loading templates by ID (e.g., "welcome-email", "password-reset-sms") + * - Rendering templates with variables (e.g., {{ userName }} → "John Doe") + * - Returning rendered content (title, body, HTML) + * + * Why abstract this? + * - Allows different template engines (Handlebars, Mustache, EJS, etc.) + * - Supports loading from different sources (filesystem, database, API) + * - Testable (mock template engine for tests) + * - Swappable implementation without changing core logic + * + * Infrastructure implementations: + * - HandlebarsTemplateEngine (uses Handlebars syntax) + * - FileSystemTemplateEngine (loads from .hbs files) + * - DatabaseTemplateEngine (loads from database) + * - NoOpTemplateEngine (for apps not using templates) + * + * Template structure example: + * ```handlebars + * Title: Welcome to {{appName}}! + * Body: Hi {{userName}}, thanks for joining {{appName}}. + * Your account is now active. + * HTML:

Welcome to {{appName}}!

+ *

Hi {{userName}}, thanks for joining.

+ * ``` */ export interface ITemplateEngine { /** * Render a template with variables + * + * @param _templateId - Unique template identifier (e.g., "welcome-email") + * @param _variables - Key-value pairs for variable substitution + * @returns Promise - Rendered title, body, and HTML + * @throws TemplateError - If template doesn't exist or rendering fails + * + * Example: + * ```typescript + * const result = await templateEngine.render("welcome-email", { + * userName: "John Doe", + * appName: "MyApp" + * }); + * // Returns: { title: "Welcome to MyApp!", body: "Hi John Doe, ...", html: "

..." } + * ``` */ render(_templateId: string, _variables: Record): Promise; /** * Check if a template exists + * + * @param _templateId - Template ID to check + * @returns Promise - true if template exists, false otherwise + * + * Useful for validation before attempting to render */ hasTemplate(_templateId: string): Promise; /** - * Validate template variables + * Validate that all required template variables are provided + * + * @param _templateId - Template ID + * @param _variables - Variables to validate + * @returns Promise - true if all required variables present + * + * Example: + * If template requires {{ userName }} and {{ code }}, this checks both are provided */ validateVariables(_templateId: string, _variables: Record): Promise; } /** * Result of template rendering + * + * Contains the rendered content ready to be sent. + * - title: Used as email subject, push notification title, etc. + * - body: Plain text content + * - html: HTML content (for email, optional) */ export interface TemplateResult { - title: string; - body: string; - html?: string; + title: string; // Rendered title/subject + body: string; // Rendered plain text body + html?: string; // Rendered HTML body (optional, mainly for email) } /** - * Port: ID generator abstraction + * Port: ID Generator - Abstraction for generating unique IDs + * + * This port allows pluggable ID generation strategies. + * + * Why abstract this? + * - Different ID formats (UUID, nanoid, ULID, Snowflake, etc.) + * - Testable (predictable IDs in tests) + * - Consistent ID format across the system + * + * Infrastructure implementations: + * - UUIDGenerator (uses uuid v4) + * - NanoidGenerator (uses nanoid) + * - ULIDGenerator (uses ULID - sortable by time) + * - IncrementalIdGenerator (for testing) */ export interface IIdGenerator { /** - * Generate a unique ID + * Generate a unique ID for a notification + * + * @returns string - A unique identifier + * + * Requirements: + * - Must be globally unique (no collisions) + * - Should be URL-safe + * - Recommended: Sortable by creation time (ULID) + * + * Example implementations: + * - UUID v4: "550e8400-e29b-41d4-a716-446655440000" + * - Nanoid: "V1StGXR8_Z5jdHi6B-myT" + * - ULID: "01ARZ3NDEKTSV4RRFFQ69G5FAV" */ generate(): string; } /** - * Port: Date/time provider abstraction + * Port: Date/Time Provider - Abstraction for date/time operations + * + * Why abstract date/time? + * - Testability: Mock current time in tests + * - Consistency: All timestamps in same format (ISO 8601) + * - Timezone handling: Normalize to UTC + * + * Infrastructure implementations: + * - SystemDateTimeProvider (uses system time) + * - FixedDateTimeProvider (for testing - returns fixed time) */ export interface IDateTimeProvider { /** - * Get current ISO 8601 timestamp + * Get current timestamp in ISO 8601 format + * + * @returns string - Current UTC time as ISO 8601 + * + * Example: "2026-03-31T14:30:00.000Z" + * + * Used for: + * - Setting createdAt, updatedAt timestamps + * - Recording sentAt, deliveredAt times + * - Comparing with scheduledFor dates */ now(): string; /** * Check if a datetime is in the past + * + * @param _datetime - ISO 8601 datetime string + * @returns boolean - true if datetime < now + * + * Used for: + * - Validating scheduled dates + * - Finding expired items */ isPast(_datetime: string): boolean; /** * Check if a datetime is in the future + * + * @param _datetime - ISO 8601 datetime string + * @returns boolean - true if datetime > now + * + * Used for: + * - Checking if notification is scheduled for future + * - Validating input dates */ isFuture(_datetime: string): boolean; } /** - * Port: Notification queue abstraction (for async processing) + * Port: Notification Queue - Abstraction for async notification queue + * + * The queue is used for asynchronous notification processing: + * - Decouple notification creation from sending + * - Handle high-volume notification bursts + * - Prioritize urgent notifications + * - Retry failed notifications + * + * Why use a queue? + * - Performance: Don't block API responses waiting for sends + * - Reliability: Persist notifications if sender is temporarily down + * - Scalability: Multiple workers can process queue in parallel + * - Rate limiting: Control send rate to avoid provider limits + * + * Infrastructure implementations: + * - RedisQueue (Redis-based queue with prioritization) + * - BullMQQueue (Bull queue with advanced features) + * - SQSQueue (AWS SQS) + * - InMemoryQueue (for testing/development) + * + * Typical flow: + * 1. API request creates notification → notification.status = QUEUED + * 2. Notification ID is added to queue + * 3. Worker dequeues notification ID + * 4. Worker sends notification and updates status */ export interface INotificationQueue { /** - * Add a notification to the queue + * Add a notification to the queue for async processing + * + * @param _notificationId - ID of notification to queue + * @param _priority - Optional priority (urgent notifications processed first) + * @returns Promise + * + * Example: + * ```typescript + * // Create notification + * const notification = await service.create(dto); + * + * // Queue for async processing + * await queue.enqueue(notification.id, notification.priority); + * ``` */ enqueue(_notificationId: string, _priority?: string): Promise; /** - * Remove a notification from the queue + * Remove and return the next notification ID from the queue + * + * @returns Promise - Next notification ID, or null if queue is empty + * + * Worker loop example: + * ```typescript + * while (true) { + * const notificationId = await queue.dequeue(); + * if (notificationId) { + * await notificationService.sendById(notificationId); + * } else { + * await sleep(1000); // Wait if queue is empty + * } + * } + * ``` */ dequeue(): Promise; /** - * Get queue size + * Get the current size of the queue + * + * @returns Promise - Number of notifications waiting in queue + * + * Useful for monitoring and alerting (e.g., alert if queue size > 10000) */ size(): Promise; /** - * Clear the queue + * Clear all notifications from the queue + * + * @returns Promise + * + * Use with caution - typically only for testing or emergency queue purges */ clear(): Promise; } /** - * Port: Event emitter abstraction (for notification events) + * Port: Event Emitter - Abstraction for publishing notification lifecycle events + * + * The event emitter publishes events for monitoring, logging, analytics, and + * integrations throughout the notification lifecycle. + * + * Why emit events? + * - Monitoring: Track send success/failure rates + * - Analytics: Measure notification engagement + * - Logging: Audit trail of all notifications + * - Integrations: Trigger webhooks, update CRM, send to data warehouse + * - Real-time updates: WebSocket updates to admin dashboard + * + * Infrastructure implementations: + * - EventEmitter2EventEmitter (Node.js EventEmitter2) + * - KafkaEventEmitter (publish to Kafka topics) + * - SQSEventEmitter (publish to AWS SQS) + * - WebhookEventEmitter (HTTP webhooks) + * - CompositeEventEmitter (emit to multiple destinations) + * + * Event flow example: + * 1. notification.created → Log to console, send to analytics + * 2. notification.sending → Update dashboard with "sending" status + * 3. notification.sent → Increment Prometheus counter, log success + * 4. notification.delivered → Update CRM with delivery confirmation */ export interface INotificationEventEmitter { /** - * Emit an event + * Emit a notification lifecycle event + * + * @param _event - The event to emit (see NotificationEvent type) + * @returns Promise + * + * Events should be fire-and-forget. Event emission failures should not + * prevent notification processing (log errors but don't throw). + * + * Example implementation: + * ```typescript + * async emit(event: NotificationEvent) { + * try { + * console.log(`[EVENT] ${event.type}`, event); + * await kafka.send({ topic: 'notifications', messages: [{ value: JSON.stringify(event) }] }); + * } catch (error) { + * console.error('Failed to emit event:', error); + * // Don't throw - event emission is non-critical + * } + * } + * ``` */ emit(_event: NotificationEvent): Promise; } /** - * Notification events + * Notification lifecycle events + * + * These are all possible events emitted during a notification's lifecycle. + * Each event type has a specific structure with relevant data. + * + * Event types: + * + * 1. "notification.created" - Notification was created + * - Contains: notification entity + * - When: After create() succeeds + * + * 2. "notification.queued" - Notification was added to async queue + * - Contains: notification entity + * - When: After enqueue() succeeds + * + * 3. "notification.sending" - Send operation started + * - Contains: notification entity (status = SENDING) + * - When: Before calling sender.send() + * + * 4. "notification.sent" - Send succeeded + * - Contains: notification entity (status = SENT), send result + * - When: After sender.send() returns success + * + * 5. "notification.delivered" - Provider confirmed delivery + * - Contains: notification entity (status = DELIVERED) + * - When: Webhook callback from provider + * + * 6. "notification.failed" - Send failed + * - Contains: notification entity (status = FAILED), error message + * - When: After sender.send() returns failure or throws error + * + * 7. "notification.cancelled" - Notification was cancelled + * - Contains: notification entity (status = CANCELLED) + * - When: After cancel() succeeds */ export type NotificationEvent = | { type: "notification.created"; notification: Notification } diff --git a/src/core/types.ts b/src/core/types.ts index e71d684..ed1a584 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,35 +1,57 @@ +/** + * @file Core Domain Types and Interfaces + * + * This file defines all the core domain types, enums, and interfaces for the NotificationKit system. + * It contains the fundamental building blocks that represent notifications in the system: + * + * - NotificationChannel: Enum defining available delivery channels (Email, SMS, Push, etc.) + * - NotificationStatus: Enum tracking the lifecycle state of a notification + * - NotificationPriority: Enum for categorizing notification urgency + * - NotificationRecipient: Interface describing who receives the notification + * - NotificationContent: Interface describing what the notification contains + * - Notification: Main domain entity representing a complete notification + * - NotificationResult: Interface for send operation results + * + * These types are used throughout the entire system and form the core vocabulary + * of the notification domain. + */ + /** * Notification channel types + * Defines the different delivery mechanisms available for sending notifications */ export enum NotificationChannel { - EMAIL = "email", - SMS = "sms", - PUSH = "push", - IN_APP = "in_app", - WEBHOOK = "webhook", + EMAIL = "email", // Email delivery via SMTP or email service providers + SMS = "sms", // SMS text messages via telecom providers + PUSH = "push", // Mobile push notifications via FCM, APNs, etc. + IN_APP = "in_app", // In-application notifications (stored for retrieval) + WEBHOOK = "webhook", // HTTP webhook callbacks to external systems + WHATSAPP = "whatsapp", // WhatsApp messages via Twilio or Meta Business API } /** * Notification status lifecycle + * Tracks the current state of a notification through its delivery process */ export enum NotificationStatus { - PENDING = "pending", - QUEUED = "queued", - SENDING = "sending", - SENT = "sent", - DELIVERED = "delivered", - FAILED = "failed", - CANCELLED = "cancelled", + PENDING = "pending", // Created but not yet ready to send (e.g., scheduled for future) + QUEUED = "queued", // Ready to be sent, waiting in queue + SENDING = "sending", // Currently being sent to provider + SENT = "sent", // Successfully sent to provider (but not yet confirmed delivered) + DELIVERED = "delivered", // Confirmed delivered to recipient + FAILED = "failed", // Send attempt failed (may retry based on configuration) + CANCELLED = "cancelled", // Manually cancelled before sending } /** * Notification priority levels + * Used for queue ordering and handling urgency */ export enum NotificationPriority { - LOW = "low", - NORMAL = "normal", - HIGH = "high", - URGENT = "urgent", + LOW = "low", // Low priority, can be delayed (e.g., newsletters, digests) + NORMAL = "normal", // Standard priority for most notifications + HIGH = "high", // Important, should be sent soon (e.g., alerts) + URGENT = "urgent", // Critical, send immediately (e.g., OTP codes, security alerts) } /** diff --git a/src/index.ts b/src/index.ts index f39fa91..dc0063d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,22 @@ -// Core domain layer +/** + * @file Main Entry Point for NotificationKit + * + * This file serves as the primary export point for the entire NotificationKit package. + * It exports all modules from three main layers following clean architecture principles: + * + * 1. Core Layer (./core): Framework-agnostic business logic, types, and domain entities + * 2. Infrastructure Layer (./infra): Concrete implementations of senders, repositories, and providers + * 3. NestJS Integration Layer (./nest): NestJS-specific module configuration and decorators + * + * Usage: Import from this file to access any NotificationKit functionality + * Example: import { NotificationKitModule, NodemailerSender, NotificationChannel } from '@ciscode/notification-kit' + */ + +// Core domain layer - Contains pure business logic and domain models export * from "./core"; -// Infrastructure layer +// Infrastructure layer - Contains provider implementations and adapters export * from "./infra"; -// NestJS integration layer +// NestJS integration layer - Contains NestJS module, controllers, and decorators export * from "./nest"; diff --git a/src/infra/senders/email/nodemailer.sender.ts b/src/infra/senders/email/nodemailer.sender.ts index 6855b63..0231a79 100644 --- a/src/infra/senders/email/nodemailer.sender.ts +++ b/src/infra/senders/email/nodemailer.sender.ts @@ -1,3 +1,48 @@ +/** + * Nodemailer Email Sender - SMTP Email Delivery + * + * This is the email sender implementation using Nodemailer, which supports + * any SMTP provider (Gmail, SendGrid, AWS SES, Mailgun, etc.). + * + * Features: + * - SMTP support: Works with any SMTP server + * - HTML emails: Supports both plain text and HTML content + * - Lazy loading: Nodemailer is loaded only when needed (peer dependency) + * - Connection verification: isReady() checks SMTP connection + * - Email validation: Validates email format before sending + * + * Supported providers (any SMTP server): + * - Gmail (smtp.gmail.com:587) + * - SendGrid (smtp.sendgrid.net:587) + * - AWS SES (email-smtp.us-east-1.amazonaws.com:587) + * - Mailgun (smtp.mailgun.org:587) + * - Office 365 (smtp.office365.com:587) + * - Custom SMTP servers + * + * Configuration example: + * ```typescript + * const emailSender = new NodemailerSender({ + * host: 'smtp.gmail.com', + * port: 587, + * secure: false, + * auth: { + * user: 'your-email@gmail.com', + * pass: 'your-app-password' + * }, + * from: 'noreply@yourapp.com', + * fromName: 'Your App Name' + * }); + * ``` + * + * Usage with NotificationKit: + * ```typescript + * NotificationKitModule.forRoot({ + * senders: [emailSender], + * // ... other config + * }); + * ``` + */ + import type { INotificationSender, NotificationChannel, @@ -6,57 +51,96 @@ import type { NotificationResult, } from "../../../core"; +/** + * Configuration for Nodemailer SMTP transport + * + * This matches the Nodemailer transport configuration structure. + * See: https://nodemailer.com/smtp/ + */ export interface NodemailerConfig { - host: string; - port: number; - secure?: boolean | undefined; - auth?: + host: string; // SMTP server hostname (e.g., "smtp.gmail.com") + port: number; // SMTP port (587 for TLS, 465 for SSL, 25 for unencrypted) + secure?: boolean | undefined; // true for port 465 (SSL), false for other ports (TLS) + auth?: // SMTP authentication credentials | { - user: string; - pass: string; + user: string; // SMTP username (usually your email) + pass: string; // SMTP password (use app-specific password for Gmail) } | undefined; - from: string; - fromName?: string | undefined; + from: string; // Default "from" email address + fromName?: string | undefined; // Optional "from" display name } /** * Email sender implementation using Nodemailer - * Supports any SMTP provider (Gmail, SendGrid, AWS SES, etc.) + * + * Implements the INotificationSender port for email notifications. + * Uses Nodemailer (https://nodemailer.com/) for SMTP email delivery. */ export class NodemailerSender implements INotificationSender { readonly channel: NotificationChannel = "email" as NotificationChannel; + + // Transporter is created lazily and cached for reuse + // This avoids creating multiple SMTP connections private transporter: any = null; constructor(private readonly config: NodemailerConfig) {} /** - * Initialize the nodemailer transporter lazily + * Initialize the nodemailer transporter (lazy initialization) + * + * Why lazy initialization? + * - Nodemailer is a peer dependency (may not be installed) + * - Connection is only needed when actually sending emails + * - Avoids startup errors if SMTP is misconfigured + * - Transporter is reused for all sends (connection pooling) + * + * @returns Promise - Nodemailer transporter instance + * @private */ private async getTransporter(): Promise { + // Return cached transporter if already created if (this.transporter) { return this.transporter; } - // Dynamic import to avoid requiring nodemailer at build time + // Dynamic import: Load nodemailer only when needed + // This allows NotificationKit to work without nodemailer installed + // if you're only using SMS/push notifications // @ts-expect-error - nodemailer is an optional peer dependency const nodemailer = await import("nodemailer"); + // Create SMTP transporter with configured settings this.transporter = nodemailer.createTransport({ host: this.config.host, port: this.config.port, - secure: this.config.secure ?? false, + secure: this.config.secure ?? false, // Default to false (TLS on port 587) auth: this.config.auth, }); return this.transporter; } + /** + * Send an email notification + * + * This method: + * 1. Validates recipient has an email address + * 2. Gets or creates the SMTP transporter + * 3. Constructs the email (from, to, subject, text, html) + * 4. Sends via SMTP + * 5. Returns result with success status and provider message ID + * + * @param _recipient - Notification recipient (must have email field) + * @param _content - Notification content (title, body, html) + * @returns Promise - Send result + */ async send( _recipient: NotificationRecipient, _content: NotificationContent, ): Promise { try { + // Validate recipient has email address if (!_recipient.email) { return { success: false, @@ -65,31 +149,37 @@ export class NodemailerSender implements INotificationSender { }; } + // Get transporter (creates if not exists) const transporter = await this.getTransporter(); + // Construct email options const mailOptions = { + // From address: Use "Display Name " format if fromName provided from: this.config.fromName ? `"${this.config.fromName}" <${this.config.from}>` : this.config.from, - to: _recipient.email, - subject: _content.title, - text: _content.body, - html: _content.html, + to: _recipient.email, // Recipient email + subject: _content.title, // Email subject + text: _content.body, // Plain text body + html: _content.html, // HTML body (optional, falls back to text) }; + // Send the email via SMTP const info = await transporter.sendMail(mailOptions); + // Return success with provider message ID (for tracking) return { success: true, notificationId: _recipient.id, - providerMessageId: info.messageId, + providerMessageId: info.messageId, // Nodemailer message ID metadata: { - accepted: info.accepted, - rejected: info.rejected, - response: info.response, + accepted: info.accepted, // Accepted recipients + rejected: info.rejected, // Rejected recipients + response: info.response, // SMTP server response }, }; } catch (error) { + // Return failure with error message return { success: false, notificationId: _recipient.id, @@ -98,20 +188,54 @@ export class NodemailerSender implements INotificationSender { } } + /** + * Check if the email sender is ready to send + * + * This method verifies the SMTP connection by attempting to connect + * to the SMTP server. If connection fails, sending won't work. + * + * @returns Promise - true if SMTP connection works, false otherwise + * + * Called by NotificationService before sending to ensure sender is ready. + * Prevents attempting sends when SMTP is misconfigured or unreachable. + */ async isReady(): Promise { try { const transporter = await this.getTransporter(); - await transporter.verify(); + await transporter.verify(); // Verifies SMTP connection return true; } catch { - return false; + return false; // SMTP connection failed (wrong credentials, server down, etc.) } } + /** + * Validate recipient has valid email address + * + * Checks that: + * 1. Recipient has an email field + * 2. Email format is valid (basic regex check) + * + * @param _recipient - Recipient to validate + * @returns boolean - true if valid, false otherwise + * + * Note: This is a basic format check, not a deliverability check. + * The email could still bounce if it doesn't exist. + */ validateRecipient(_recipient: NotificationRecipient): boolean { return !!_recipient.email && this.isValidEmail(_recipient.email); } + /** + * Validate email format using regex + * + * Basic email validation: checks for user@domain.tld format. + * This is not RFC-compliant but catches most invalid formats. + * + * @param email - Email address to validate + * @returns boolean - true if format looks valid + * @private + */ private isValidEmail(email: string): boolean { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); diff --git a/src/infra/senders/index.ts b/src/infra/senders/index.ts index 77ceae4..ba0fede 100644 --- a/src/infra/senders/index.ts +++ b/src/infra/senders/index.ts @@ -6,6 +6,9 @@ export * from "./sms/twilio.sender"; export * from "./sms/aws-sns.sender"; export * from "./sms/vonage.sender"; +// WhatsApp senders +export * from "./whatsapp"; + // Push notification senders export * from "./push/firebase.sender"; export * from "./push/onesignal.sender"; diff --git a/src/infra/senders/whatsapp/index.ts b/src/infra/senders/whatsapp/index.ts new file mode 100644 index 0000000..83cdbd5 --- /dev/null +++ b/src/infra/senders/whatsapp/index.ts @@ -0,0 +1,10 @@ +/** + * WhatsApp Senders - Export WhatsApp sender implementations + * + * This module exports all WhatsApp sender implementations: + * - TwilioWhatsAppSender: Real WhatsApp sender using Twilio API + * - MockWhatsAppSender: Mock sender for testing without credentials + */ + +export * from "./twilio-whatsapp.sender"; +export * from "./mock-whatsapp.sender"; diff --git a/src/infra/senders/whatsapp/mock-whatsapp.sender.ts b/src/infra/senders/whatsapp/mock-whatsapp.sender.ts new file mode 100644 index 0000000..5a169f6 --- /dev/null +++ b/src/infra/senders/whatsapp/mock-whatsapp.sender.ts @@ -0,0 +1,181 @@ +/** + * Mock WhatsApp Sender - Testing WhatsApp Without Real API + * + * This is a mock implementation of the WhatsApp sender for testing and development + * purposes. It simulates sending WhatsApp messages without requiring actual Twilio + * credentials or making real API calls. + * + * Features: + * - No credentials required: Works immediately without setup + * - Always succeeds: Simulates successful message delivery + * - Console logging: Outputs what would be sent (for debugging) + * - Media support: Logs media URLs that would be sent + * - Template support: Logs template usage + * - Fast: No network calls, instant responses + * + * Use cases: + * - Local development without Twilio account + * - Testing notification flows + * - Demo applications + * - CI/CD pipelines without credentials + * + * Configuration example: + * ```typescript + * const mockSender = new MockWhatsAppSender({ + * logMessages: true // Optional: log to console (default: true) + * }); + * ``` + * + * Usage with NotificationKit: + * ```typescript + * NotificationKitModule.forRoot({ + * senders: [mockSender], + * // ... other config + * }); + * ``` + */ + +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +/** + * Configuration for Mock WhatsApp sender + */ +export interface MockWhatsAppConfig { + /** + * Whether to log messages to console (default: true) + * Useful for debugging and seeing what would be sent + */ + logMessages?: boolean; +} + +/** + * Mock WhatsApp sender for testing + * + * Implements the INotificationSender port but doesn't actually send messages. + * Perfect for development, testing, and demos. + */ +export class MockWhatsAppSender implements INotificationSender { + readonly channel: NotificationChannel = "whatsapp" as NotificationChannel; + + constructor(private readonly config: MockWhatsAppConfig = { logMessages: true }) {} + + /** + * Simulate sending a WhatsApp message + * + * This method: + * 1. Validates recipient has phone number + * 2. Logs what would be sent (if logging enabled) + * 3. Returns mock success response + * 4. Never actually sends anything + * + * @param _recipient - Notification recipient + * @param _content - Notification content + * @returns Promise - Mock success result + */ + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + // Validate recipient has phone + if (!_recipient.phone) { + return { + success: false, + notificationId: _recipient.id, + error: "Recipient phone number is required for WhatsApp", + }; + } + + // Validate phone format + if (!this.isValidPhoneNumber(_recipient.phone)) { + return { + success: false, + notificationId: _recipient.id, + error: `Invalid phone number format. Must be E.164 format (e.g., +1234567890). Got: ${_recipient.phone}`, + }; + } + + // Log what would be sent (if enabled) + if (this.config.logMessages) { + console.log("\n═══════════════════════════════════════════"); + console.log("šŸ“± [MockWhatsApp] Simulating WhatsApp send"); + console.log("═══════════════════════════════════════════"); + console.log(`To: ${_recipient.phone}`); + console.log(`Recipient ID: ${_recipient.id}`); + + if (_content.templateId) { + console.log(`\nšŸ“‹ Template: ${_content.templateId}`); + if (_content.templateVars) { + console.log(`Variables: ${JSON.stringify(_content.templateVars, null, 2)}`); + } + } else { + console.log(`\nšŸ’¬ Message: ${_content.body}`); + } + + const mediaUrl = _content.data?.mediaUrl as string | undefined; + if (mediaUrl) { + console.log(`šŸ“Ž Media: ${mediaUrl}`); + } + + console.log("═══════════════════════════════════════════\n"); + } + + // Return mock success + return { + success: true, + notificationId: _recipient.id, + providerMessageId: `mock-whatsapp-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, + metadata: { + status: "sent", + mock: true, + timestamp: new Date().toISOString(), + recipient: _recipient.phone, + messageType: _content.templateId ? "template" : "text", + hasMedia: !!_content.data?.mediaUrl, + }, + }; + } + + /** + * Mock always ready + * + * Since this is a mock sender that doesn't require credentials, + * it's always ready to "send" (simulate sending). + * + * @returns Promise - Always true + */ + async isReady(): Promise { + return true; + } + + /** + * Validate recipient has phone number in E.164 format + * + * @param _recipient - Recipient to validate + * @returns boolean - true if phone exists and is valid + */ + validateRecipient(_recipient: NotificationRecipient): boolean { + return !!_recipient.phone && this.isValidPhoneNumber(_recipient.phone); + } + + /** + * Validate phone number is in E.164 format + * + * E.164 format: +[country code][number] + * Examples: +14155551234, +447911123456, +212612345678 + * + * @param phone - Phone number to validate + * @returns boolean - true if valid E.164 format + * @private + */ + private isValidPhoneNumber(phone: string): boolean { + // E.164 format: + followed by 1-15 digits + const phoneRegex = /^\+[1-9]\d{1,14}$/; + return phoneRegex.test(phone); + } +} diff --git a/src/infra/senders/whatsapp/twilio-whatsapp.sender.ts b/src/infra/senders/whatsapp/twilio-whatsapp.sender.ts new file mode 100644 index 0000000..a37e9b1 --- /dev/null +++ b/src/infra/senders/whatsapp/twilio-whatsapp.sender.ts @@ -0,0 +1,323 @@ +/** + * Twilio WhatsApp Sender - WhatsApp Message Delivery via Twilio + * + * This is the WhatsApp sender implementation using Twilio's WhatsApp API, + * which provides an easy way to send WhatsApp messages without requiring + * direct Meta Business API approval. + * + * Features: + * - WhatsApp messaging: Send text messages via WhatsApp + * - Media support: Send images, videos, PDFs, and other documents + * - Template support: Use pre-approved WhatsApp message templates (configurable) + * - Lazy loading: Twilio SDK is loaded only when needed (peer dependency) + * - Connection verification: isReady() checks Twilio credentials + * - Phone validation: Validates E.164 format before sending + * + * Requirements: + * - Twilio account with WhatsApp enabled + * - WhatsApp Sandbox (for testing) or approved WhatsApp Business Profile + * - Recipients must opt-in to receive messages (Sandbox requirement) + * - Messages must use approved templates for certain use cases + * + * Configuration example: + * ```typescript + * const whatsappSender = new TwilioWhatsAppSender({ + * accountSid: 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + * authToken: 'your-auth-token', + * fromNumber: '+14155238886', // Your Twilio WhatsApp number + * templates: { + * orderShipped: 'order_shipped_v1', + * welcomeMessage: 'welcome_v2' + * } + * }); + * ``` + * + * Usage with NotificationKit: + * ```typescript + * NotificationKitModule.forRoot({ + * senders: [whatsappSender], + * // ... other config + * }); + * ``` + * + * Media support example: + * ```typescript + * await notificationService.send({ + * channel: NotificationChannel.WHATSAPP, + * recipient: { id: 'user-123', phone: '+1234567890' }, + * content: { + * title: 'Invoice', + * body: 'Here is your invoice', + * data: { + * mediaUrl: 'https://example.com/invoice.pdf' + * } + * } + * }); + * ``` + */ + +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +/** + * Configuration for Twilio WhatsApp sender + * + * This configuration matches Twilio's WhatsApp API requirements. + * See: https://www.twilio.com/docs/whatsapp/api + */ +export interface TwilioWhatsAppConfig { + accountSid: string; // Twilio Account SID (starts with AC...) + authToken: string; // Twilio Auth Token (from console) + fromNumber: string; // Your Twilio WhatsApp-enabled phone number (E.164 format: +14155238886) + + /** + * Optional: WhatsApp message templates (configurable) + * + * Templates are required for certain types of messages (promotional, etc.) + * in the WhatsApp Business API. Define your approved templates here. + * + * Usage in notification: + * ```typescript + * content: { + * templateId: 'orderShipped', // maps to 'order_shipped_v1' + * templateVars: { orderId: '12345' } + * } + * ``` + */ + templates?: Record; +} + +/** + * WhatsApp sender implementation using Twilio API + * + * Implements the INotificationSender port for WhatsApp notifications. + * Uses Twilio's WhatsApp API (https://www.twilio.com/docs/whatsapp) for message delivery. + */ +export class TwilioWhatsAppSender implements INotificationSender { + readonly channel: NotificationChannel = "whatsapp" as NotificationChannel; + + // Twilio client is created lazily and cached for reuse + // This avoids creating multiple connections to Twilio + private client: any = null; + + constructor(private readonly config: TwilioWhatsAppConfig) {} + + /** + * Initialize the Twilio client (lazy initialization) + * + * Why lazy initialization? + * - Twilio is a peer dependency (may not be installed) + * - Connection is only needed when actually sending messages + * - Avoids startup errors if Twilio is misconfigured + * - Client is reused for all sends (connection pooling) + * + * @returns Promise - Twilio client instance + * @private + */ + private async getClient(): Promise { + // Return cached client if already created + if (this.client) { + return this.client; + } + + // Dynamic import: Load Twilio SDK only when needed + // This allows NotificationKit to work without Twilio installed + // if you're only using email/push notifications + // @ts-expect-error - twilio is an optional peer dependency + const twilio = await import("twilio"); + + // Create Twilio client with credentials + this.client = twilio.default(this.config.accountSid, this.config.authToken); + + return this.client; + } + + /** + * Send a WhatsApp message + * + * This method: + * 1. Validates recipient has a phone number + * 2. Validates phone is in E.164 format + * 3. Gets or creates the Twilio client + * 4. Formats phone numbers for WhatsApp (prefixes with "whatsapp:") + * 5. Checks if using a template or plain message + * 6. Sends message via Twilio WhatsApp API + * 7. Returns result with success status and provider message SID + * + * @param _recipient - Notification recipient (must have phone field) + * @param _content - Notification content (body, optional mediaUrl, optional templateId) + * @returns Promise - Send result + */ + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + // Validate recipient has phone number + if (!_recipient.phone) { + return { + success: false, + notificationId: _recipient.id, + error: "Recipient phone number is required for WhatsApp", + }; + } + + // Validate phone number format (E.164) + if (!this.isValidPhoneNumber(_recipient.phone)) { + return { + success: false, + notificationId: _recipient.id, + error: `Invalid phone number format. Must be E.164 format (e.g., +1234567890). Got: ${_recipient.phone}`, + }; + } + + // Get Twilio client (creates if not exists) + const client = await this.getClient(); + + // Format phone numbers for WhatsApp (Twilio requires "whatsapp:" prefix) + const fromWhatsApp = `whatsapp:${this.config.fromNumber}`; + const toWhatsApp = `whatsapp:${_recipient.phone}`; + + // Prepare message options + const messageOptions: any = { + from: fromWhatsApp, + to: toWhatsApp, + }; + + // Check if using a template + if (_content.templateId && this.config.templates?.[_content.templateId]) { + // Template message (for WhatsApp Business API requirements) + const templateName = this.config.templates[_content.templateId]; + + messageOptions.contentSid = templateName; + + // Add template variables if provided + if (_content.templateVars) { + messageOptions.contentVariables = JSON.stringify(_content.templateVars); + } + } else { + // Plain text message + messageOptions.body = _content.body; + } + + // Add media URL if provided (images, videos, PDFs, etc.) + // WhatsApp supports: image/*, video/*, audio/*, application/pdf, and more + const mediaUrl = _content.data?.mediaUrl as string | undefined; + if (mediaUrl) { + messageOptions.mediaUrl = [mediaUrl]; + } + + // Send the message via Twilio WhatsApp API + const message = await client.messages.create(messageOptions); + + // Return success with provider message SID (for tracking) + return { + success: true, + notificationId: _recipient.id, + providerMessageId: message.sid, // Twilio message SID (e.g., SM...) + metadata: { + status: message.status, // Message status (queued, sent, delivered, read, failed) + dateCreated: message.dateCreated, // When message was created + dateSent: message.dateSent, // When message was sent (if available) + price: message.price, // Cost of message (if available) + priceUnit: message.priceUnit, // Currency of price + errorCode: message.errorCode, // Error code if failed + errorMessage: message.errorMessage, // Error message if failed + }, + }; + } catch (error: any) { + // Handle Twilio-specific errors + const errorMessage = error?.message || "Failed to send WhatsApp message via Twilio"; + const errorCode = error?.code || undefined; + + return { + success: false, + notificationId: _recipient.id, + error: errorCode ? `[${errorCode}] ${errorMessage}` : errorMessage, + metadata: { + errorCode, + rawError: error, + }, + }; + } + } + + /** + * Check if the WhatsApp sender is ready to send + * + * This method verifies the Twilio credentials by attempting to fetch + * the account information. If credentials are invalid, sending won't work. + * + * @returns Promise - true if Twilio credentials work, false otherwise + * + * Called by NotificationService before sending to ensure sender is ready. + * Prevents attempting sends when Twilio is misconfigured or unreachable. + */ + async isReady(): Promise { + try { + const client = await this.getClient(); + + // Verify credentials by fetching account info + await client.api.accounts(this.config.accountSid).fetch(); + + return true; + } catch (error) { + // Credentials invalid or Twilio unreachable + console.error( + "[TwilioWhatsAppSender] Not ready:", + error instanceof Error ? error.message : error, + ); + return false; + } + } + + /** + * Validate recipient has required fields for WhatsApp + * + * WhatsApp requires: + * - phone: Must exist + * - phone: Must be in E.164 format (+[country code][number]) + * + * @param _recipient - The recipient to validate + * @returns boolean - true if recipient is valid for WhatsApp + * + * Called by NotificationService before attempting to send. + */ + validateRecipient(_recipient: NotificationRecipient): boolean { + return !!_recipient.phone && this.isValidPhoneNumber(_recipient.phone); + } + + /** + * Validate phone number is in E.164 format + * + * E.164 format: +[country code][number] + * - Starts with + + * - Followed by 1-3 digit country code + * - Followed by up to 15 total digits + * + * Valid examples: + * - +14155551234 (USA) + * - +447911123456 (UK) + * - +212612345678 (Morocco) + * - +33612345678 (France) + * + * Invalid examples: + * - 4155551234 (missing +) + * - +1-415-555-1234 (contains dashes) + * - +1 (415) 555-1234 (contains spaces and parentheses) + * + * @param phone - Phone number to validate + * @returns boolean - true if valid E.164 format + * @private + */ + private isValidPhoneNumber(phone: string): boolean { + // E.164 format regex: + followed by 1-15 digits, no spaces or special chars + const phoneRegex = /^\+[1-9]\d{1,14}$/; + return phoneRegex.test(phone); + } +} diff --git a/src/nest/index.ts b/src/nest/index.ts index ffd8c09..6c630f2 100644 --- a/src/nest/index.ts +++ b/src/nest/index.ts @@ -1,18 +1,74 @@ -// Module +/** + * NotificationKit NestJS Integration - Public API + * + * This file exports all public NestJS integration components for NotificationKit. + * Import from '@ciscode/notification-kit/nest' to use NotificationKit with NestJS. + * + * What's exported: + * - NotificationKitModule: Main module to import in your app + * - Interfaces: TypeScript interfaces for configuration + * - Constants: Injection tokens for DI + * - Decorators: Custom decorators (if any) + * - Controllers: REST API and webhook controllers + * - Providers: Factory functions for creating providers + * + * Quick start: + * ```typescript + * import { NotificationKitModule } from '@ciscode/notification-kit/nest'; + * import { NodemailerSender } from '@ciscode/notification-kit'; + * + * @Module({ + * imports: [ + * NotificationKitModule.register({ + * senders: [ + * new NodemailerSender({ + * host: 'smtp.gmail.com', + * port: 587, + * auth: { user: 'your@email.com', pass: 'app-password' }, + * from: 'noreply@yourapp.com', + * }), + * ], + * repository: new InMemoryRepository(), + * }), + * ], + * }) + * export class AppModule {} + * ``` + * + * To inject NotificationService: + * ```typescript + * import { NotificationService } from '@ciscode/notification-kit'; + * + * @Injectable() + * export class MyService { + * constructor(private readonly notificationService: NotificationService) {} + * + * async sendWelcomeEmail(user: User) { + * await this.notificationService.send({ + * channel: 'email', + * recipient: { id: user.id, email: user.email }, + * content: { title: 'Welcome!', body: 'Thanks for signing up' }, + * }); + * } + * } + * ``` + */ + +// Module - Main NestJS module export * from "./module"; -// Interfaces +// Interfaces - TypeScript types for configuration export * from "./interfaces"; -// Constants +// Constants - Injection tokens for dependency injection export * from "./constants"; -// Decorators +// Decorators - Custom decorators for controllers/services export * from "./decorators"; -// Controllers +// Controllers - REST API and webhook endpoints export * from "./controllers/notification.controller"; export * from "./controllers/webhook.controller"; -// Providers +// Providers - Factory functions for creating NestJS providers export * from "./providers"; diff --git a/src/nest/module.ts b/src/nest/module.ts index 1775f11..34aa398 100644 --- a/src/nest/module.ts +++ b/src/nest/module.ts @@ -1,3 +1,69 @@ +/** + * NotificationKit NestJS Module + * + * This is the main module for integrating NotificationKit with NestJS applications. + * It provides dynamic module configuration with both synchronous and asynchronous + * registration methods. + * + * Features: + * - Dynamic module: Configure at runtime with different options + * - Global module: Services available across entire application + * - Async support: Load configuration from ConfigService, database, etc. + * - Optional REST API: Built-in endpoints for sending/querying notifications + * - Optional webhooks: Endpoints for provider callbacks (Twilio, SendGrid, etc.) + * + * Usage - Synchronous (direct configuration): + * ```typescript + * @Module({ + * imports: [ + * NotificationKitModule.register({ + * senders: [emailSender, smsSender], + * repository: mongoRepository, + * enableRestApi: true, + * enableWebhooks: true, + * idGenerator: new ULIDGenerator(), + * }), + * ], + * }) + * export class AppModule {} + * ``` + * + * Usage - Asynchronous (with ConfigService): + * ```typescript + * @Module({ + * imports: [ + * NotificationKitModule.registerAsync({ + * imports: [ConfigModule], + * useFactory: (config: ConfigService) => ({ + * senders: [ + * new NodemailerSender({ + * host: config.get('SMTP_HOST'), + * port: config.get('SMTP_PORT'), + * auth: { + * user: config.get('SMTP_USER'), + * pass: config.get('SMTP_PASS'), + * }, + * from: config.get('FROM_EMAIL'), + * }), + * ], + * repository: new MongoNotificationRepository(connection), + * enableRestApi: true, + * }), + * inject: [ConfigService], + * }), + * ], + * }) + * export class AppModule {} + * ``` + * + * What gets registered: + * - NotificationService: Main business logic service (injectable) + * - Senders: Email, SMS, push notification providers + * - Repository: Database persistence layer + * - Providers: ID generator, date/time, template engine, event emitter + * - Controllers (optional): REST API and webhook endpoints + */ + import { Module, type DynamicModule, type Provider, type Type } from "@nestjs/common"; import { NOTIFICATION_KIT_OPTIONS } from "./constants"; @@ -14,69 +80,173 @@ import { createNotificationKitProviders } from "./providers"; export class NotificationKitModule { /** * Register module synchronously with direct configuration + * + * Use this when: + * - Configuration is hardcoded or imported directly + * - No async dependencies (no ConfigService, database lookups, etc.) + * - Simple setup for development/testing + * + * @param options - NotificationKit configuration object + * @returns DynamicModule - Configured NestJS module + * + * What this does: + * 1. Creates providers (NotificationService, senders, repository, etc.) + * 2. Creates controllers (REST API, webhooks) if enabled + * 3. Exports providers for use throughout the application + * 4. Marks module as global (no need to import in every module) + * + * Example: + * ```typescript + * NotificationKitModule.register({ + * senders: [new NodemailerSender({ ... })], + * repository: new InMemoryRepository(), + * enableRestApi: true, + * enableWebhooks: false, + * }) + * ``` */ static register(options: NotificationKitModuleOptions): DynamicModule { + // Create all providers (NotificationService + dependencies) const providers = this.createProviders(options); + + // Create controllers if enabled (REST API + webhooks) const controllers = this.createControllers(options); + + // Export providers so they can be injected in other modules const exports = providers.map((p) => (typeof p === "object" && "provide" in p ? p.provide : p)); return { - global: true, - module: NotificationKitModule, - controllers, - providers, - exports, + global: true, // Module is global (providers available everywhere) + module: NotificationKitModule, // This module class + controllers, // REST API + webhook controllers (if enabled) + providers, // All services and dependencies + exports, // Make providers available for injection }; } /** * Register module asynchronously with factory pattern + * + * Use this when: + * - Configuration comes from ConfigService, environment variables, etc. + * - Need to load settings from database or external API + * - Want to inject dependencies into configuration factory + * + * @param options - Async configuration options (useFactory, useClass, useExisting) + * @returns DynamicModule - Configured NestJS module + * + * Three async patterns supported: + * + * 1. useFactory: Factory function that returns configuration + * ```typescript + * registerAsync({ + * useFactory: (config: ConfigService) => ({ + * senders: [new NodemailerSender({ host: config.get('SMTP_HOST') })] + * }), + * inject: [ConfigService], + * }) + * ``` + * + * 2. useClass: Class that implements NotificationKitOptionsFactory + * ```typescript + * registerAsync({ + * useClass: NotificationKitConfigService, + * }) + * ``` + * + * 3. useExisting: Reference to existing provider + * ```typescript + * registerAsync({ + * useExisting: ConfigService, + * }) + * ``` + * + * Note: Controllers are disabled in async mode for simplicity. + * You can add them manually in a separate module if needed. */ static registerAsync(options: NotificationKitModuleAsyncOptions): DynamicModule { + // Create provider that resolves module options asynchronously const asyncOptionsProvider = this.createAsyncOptionsProvider(options); + + // Create any additional async providers (useClass providers) const asyncProviders = this.createAsyncProviders(options); - // We can't conditionally load controllers in async mode without the options - // So we'll need to always include them and they can handle being disabled internally - // Or we can create a factory provider that returns empty array + // Create a factory provider that creates NotificationKit providers + // once the module options are available const providersFactory: Provider = { provide: "NOTIFICATION_PROVIDERS", useFactory: (moduleOptions: NotificationKitModuleOptions) => { return createNotificationKitProviders(moduleOptions); }, - inject: [NOTIFICATION_KIT_OPTIONS], + inject: [NOTIFICATION_KIT_OPTIONS], // Wait for options to be resolved }; + // Combine all providers const allProviders = [asyncOptionsProvider, ...asyncProviders, providersFactory]; + + // Export providers for injection const exports = allProviders.map((p) => typeof p === "object" && "provide" in p ? p.provide : p, ); return { - global: true, - module: NotificationKitModule, - imports: options.imports || [], + global: true, // Module is global + module: NotificationKitModule, // This module class + imports: options.imports || [], // Import dependencies (ConfigModule, etc.) controllers: [], // Controllers disabled in async mode for simplicity - providers: allProviders, - exports, + providers: allProviders, // Async providers + factory + exports, // Make providers available for injection }; } /** - * Create providers including options and service providers + * Create providers including options and service providers (private helper) + * + * This creates: + * - OPTIONS provider: Configuration object + * - NotificationService: Main business logic + * - Senders: Email, SMS, push providers + * - Repository: Database persistence + * - ID generator, date/time provider, template engine, event emitter + * + * @param options - Module configuration + * @returns Provider[] - Array of NestJS providers + * @private */ private static createProviders(options: NotificationKitModuleOptions): Provider[] { return [ + // Provide options object (injectable as NOTIFICATION_KIT_OPTIONS) { provide: NOTIFICATION_KIT_OPTIONS, useValue: options, }, + // Create all NotificationKit providers (service + dependencies) ...createNotificationKitProviders(options), ]; } /** - * Create controllers based on options + * Create controllers based on options (private helper) + * + * Conditionally includes controllers based on enableRestApi and enableWebhooks flags. + * + * Controllers: + * - NotificationController: REST API endpoints for sending/querying notifications + * * POST /notifications - Send a notification + * * POST /notifications/bulk - Send to multiple recipients + * * GET /notifications - Query notifications + * * GET /notifications/:id - Get by ID + * * POST /notifications/:id/retry - Retry failed notification + * * POST /notifications/:id/cancel - Cancel notification + * + * - WebhookController: Webhook endpoints for provider callbacks + * * POST /webhooks/twilio - Twilio status callbacks + * * POST /webhooks/sendgrid - SendGrid event webhooks + * * POST /webhooks/firebase - Firebase delivery receipts + * + * @param options - Module configuration + * @returns Type[] - Array of controller classes + * @private */ private static createControllers(options: NotificationKitModuleOptions): Type[] { const controllers: Type[] = []; @@ -95,7 +265,14 @@ export class NotificationKitModule { } /** - * Create async providers for registerAsync + * Create async providers for registerAsync (private helper) + * + * When using useClass, we need to register the class as a provider + * so it can be injected into the async options factory. + * + * @param options - Async configuration options + * @returns Provider[] - Array of providers + * @private */ private static createAsyncProviders(options: NotificationKitModuleAsyncOptions): Provider[] { if (options.useClass) { @@ -111,37 +288,53 @@ export class NotificationKitModule { } /** - * Create async options provider + * Create async options provider (private helper) + * + * This creates a provider that resolves the module options asynchronously + * using one of three patterns: + * + * 1. useFactory: Direct factory function + * 2. useExisting: Factory method on existing provider + * 3. useClass: Factory method on new provider instance + * + * @param options - Async configuration options + * @returns Provider - The options provider + * @throws Error - If invalid async options provided + * @private */ private static createAsyncOptionsProvider(options: NotificationKitModuleAsyncOptions): Provider { + // Pattern 1: useFactory - Factory function that returns options if (options.useFactory) { return { provide: NOTIFICATION_KIT_OPTIONS, useFactory: options.useFactory, - inject: options.inject || [], + inject: options.inject || [], // Dependencies to inject into factory }; } + // Pattern 2: useExisting - Call createNotificationKitOptions() on existing provider if (options.useExisting) { return { provide: NOTIFICATION_KIT_OPTIONS, useFactory: async (optionsFactory: NotificationKitOptionsFactory) => { return optionsFactory.createNotificationKitOptions(); }, - inject: [options.useExisting], + inject: [options.useExisting], // Inject the existing provider }; } + // Pattern 3: useClass - Instantiate class and call createNotificationKitOptions() if (options.useClass) { return { provide: NOTIFICATION_KIT_OPTIONS, useFactory: async (optionsFactory: NotificationKitOptionsFactory) => { return optionsFactory.createNotificationKitOptions(); }, - inject: [options.useClass], + inject: [options.useClass], // Inject the new class instance }; } + // No valid async pattern provided throw new Error("Invalid NotificationKitModuleAsyncOptions"); } } diff --git a/src/nest/providers.ts b/src/nest/providers.ts index 5053a2b..e7d8bce 100644 --- a/src/nest/providers.ts +++ b/src/nest/providers.ts @@ -1,3 +1,30 @@ +/** + * NotificationKit Provider Factory + * + * This file contains the factory function for creating NestJS providers for NotificationKit. + * It handles dependency injection setup for all NotificationKit services and dependencies. + * + * What this creates: + * 1. NOTIFICATION_SENDERS: Array of notification senders (email, SMS, push) + * 2. NOTIFICATION_REPOSITORY: Database persistence layer + * 3. NOTIFICATION_ID_GENERATOR: ID generation (defaults to UUID) + * 4. NOTIFICATION_DATETIME_PROVIDER: Date/time operations (defaults to system time) + * 5. NOTIFICATION_TEMPLATE_ENGINE: Template rendering (optional) + * 6. NOTIFICATION_EVENT_EMITTER: Event emission (optional) + * 7. NOTIFICATION_SERVICE: Main NotificationService instance + * + * Dependencies: + * - Required: senders, repository + * - Optional with defaults: idGenerator (UUID), dateTimeProvider (system time) + * - Optional: templateEngine, eventEmitter + * + * The factory handles: + * - Default provider instantiation (UUID generator, system date/time) + * - Dynamic imports for optional dependencies + * - Dependency injection setup with proper injection tokens + * - Optional provider handling (templateEngine, eventEmitter) + */ + import type { Provider } from "@nestjs/common"; import { NotificationService } from "../core/notification.service"; @@ -17,30 +44,55 @@ import type { NotificationKitModuleOptions } from "./interfaces"; /** * Create providers for NotificationKit module + * + * This factory function creates all NestJS providers needed for NotificationKit + * to work. It's called by NotificationKitModule.register() and registerAsync(). + * + * @param options - NotificationKit module configuration + * @returns Provider[] - Array of NestJS provider definitions + * + * Provider creation logic: + * 1. Senders: Always required, provided as-is + * 2. Repository: Always required, provided as-is + * 3. ID Generator: Use provided, or default to UuidGenerator + * 4. DateTime Provider: Use provided, or default to DateTimeProvider (system time) + * 5. Template Engine: Use provided, or undefined (optional) + * 6. Event Emitter: Use provided, or undefined (optional) + * 7. NotificationService: Created with all dependencies injected + * + * All providers are registered with injection tokens from constants.ts, + * allowing them to be injected throughout the application. */ export function createNotificationKitProviders(options: NotificationKitModuleOptions): Provider[] { const providers: Provider[] = []; - // Senders provider + // 1. Senders provider (REQUIRED) + // Array of notification senders (email, SMS, push, etc.) + // Example: [new NodemailerSender(...), new TwilioSender(...)] providers.push({ provide: NOTIFICATION_SENDERS, useValue: options.senders, }); - // Repository provider + // 2. Repository provider (REQUIRED) + // Database persistence layer for notifications + // Example: new MongoNotificationRepository(connection) providers.push({ provide: NOTIFICATION_REPOSITORY, useValue: options.repository, }); - // ID Generator provider + // 3. ID Generator provider (optional, defaults to UUID) + // Generates unique IDs for notifications if (options.idGenerator) { + // User provided a custom ID generator providers.push({ provide: NOTIFICATION_ID_GENERATOR, useValue: options.idGenerator, }); } else { - // Default to UuidGenerator + // Default to UuidGenerator (generates UUID v4) + // Uses async factory to allow dynamic import providers.push({ provide: NOTIFICATION_ID_GENERATOR, useFactory: async () => { @@ -50,14 +102,17 @@ export function createNotificationKitProviders(options: NotificationKitModuleOpt }); } - // DateTime Provider + // 4. DateTime Provider (optional, defaults to system time) + // Provides date/time operations (now(), isPast(), isFuture()) if (options.dateTimeProvider) { + // User provided a custom dateTime provider (e.g., for testing with fixed time) providers.push({ provide: NOTIFICATION_DATETIME_PROVIDER, useValue: options.dateTimeProvider, }); } else { - // Default to DateTimeProvider + // Default to DateTimeProvider (uses system time) + // Uses async factory to allow dynamic import providers.push({ provide: NOTIFICATION_DATETIME_PROVIDER, useFactory: async () => { @@ -67,7 +122,9 @@ export function createNotificationKitProviders(options: NotificationKitModuleOpt }); } - // Template Engine provider (optional) + // 5. Template Engine provider (OPTIONAL) + // Renders notification templates with variables + // If not provided, notifications must specify content directly if (options.templateEngine) { providers.push({ provide: NOTIFICATION_TEMPLATE_ENGINE, @@ -75,7 +132,9 @@ export function createNotificationKitProviders(options: NotificationKitModuleOpt }); } - // Event Emitter provider (optional) + // 6. Event Emitter provider (OPTIONAL) + // Emits notification lifecycle events for monitoring/logging + // If not provided, events will not be emitted if (options.eventEmitter) { providers.push({ provide: NOTIFICATION_EVENT_EMITTER, @@ -83,17 +142,20 @@ export function createNotificationKitProviders(options: NotificationKitModuleOpt }); } - // NotificationService provider + // 7. NotificationService provider (MAIN SERVICE) + // Creates the main NotificationService with all dependencies injected + // This is the service you'll inject into your controllers/services providers.push({ provide: NOTIFICATION_SERVICE, useFactory: ( - repository: any, - idGenerator: any, - dateTimeProvider: any, - senders: any[], - templateEngine?: any, - eventEmitter?: any, + repository: any, // INotificationRepository implementation + idGenerator: any, // IIdGenerator implementation + dateTimeProvider: any, // IDateTimeProvider implementation + senders: any[], // Array of INotificationSender implementations + templateEngine?: any, // ITemplateEngine implementation (optional) + eventEmitter?: any, // INotificationEventEmitter implementation (optional) ) => { + // Instantiate NotificationService with all dependencies return new NotificationService( repository, idGenerator, @@ -103,13 +165,14 @@ export function createNotificationKitProviders(options: NotificationKitModuleOpt eventEmitter, ); }, + // Specify which tokens to inject into the factory function inject: [ - NOTIFICATION_REPOSITORY, - NOTIFICATION_ID_GENERATOR, - NOTIFICATION_DATETIME_PROVIDER, - NOTIFICATION_SENDERS, - { token: NOTIFICATION_TEMPLATE_ENGINE, optional: true }, - { token: NOTIFICATION_EVENT_EMITTER, optional: true }, + NOTIFICATION_REPOSITORY, // Required + NOTIFICATION_ID_GENERATOR, // Required (has default) + NOTIFICATION_DATETIME_PROVIDER, // Required (has default) + NOTIFICATION_SENDERS, // Required + { token: NOTIFICATION_TEMPLATE_ENGINE, optional: true }, // Optional + { token: NOTIFICATION_EVENT_EMITTER, optional: true }, // Optional ], });