diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cc74d32..14512f5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,201 +1,428 @@ -# Copilot Instructions - NestJS Developer Kit (Template) +# Copilot Instructions - @ciscode/notification-kit -> **Purpose**: Template for creating reusable NestJS module packages with best practices, standardized structure, and AI-friendly development workflow. +> **Purpose**: Universal NestJS notification library supporting multi-channel delivery (Email, SMS, Push, In-App, Webhook) with pluggable provider backends, template support, persistence, and a built-in REST + Webhook API. --- -## ๐ŸŽฏ Template Overview +## ๐ŸŽฏ Package Overview -**Package**: Template for `@ciscode/*` NestJS modules -**Type**: Backend NestJS Module Template -**Purpose**: Starting point for creating authentication, database, logging, and other NestJS modules +**Package**: `@ciscode/notification-kit` +**Type**: Backend NestJS Notification Module +**Purpose**: Centralized, multi-channel notification delivery with pluggable providers, retry logic, status tracking, and scheduling โ€” usable across all `@ciscode/*` services -### This Template Provides: +### This Package Provides: -- CSR (Controller-Service-Repository) architecture -- Complete TypeScript configuration with path aliases -- Jest testing setup with 80% coverage threshold +- CSR (Controller-Service-Repository) architecture with Clean Architecture ports +- `NotificationKitModule` โ€” global NestJS dynamic module (`register` / `registerAsync`) +- `NotificationService` โ€” injectable orchestration service (core, framework-free) +- `NotificationController` โ€” REST API for sending and querying notifications +- `WebhookController` โ€” inbound webhook receiver for provider delivery callbacks +- Channel senders: **Email** (Nodemailer), **SMS** (Twilio / Vonage / AWS SNS), **Push** (Firebase), **In-App**, **Webhook** +- Repository adapters: **MongoDB** (Mongoose) and **In-Memory** +- Template rendering via Handlebars +- Zod-validated configuration - Changesets for version management - Husky + lint-staged for code quality -- CI/CD workflows - Copilot-friendly development guidelines --- ## ๐Ÿ—๏ธ Module Architecture -**Modules use Controller-Service-Repository (CSR) pattern for simplicity and reusability.** +**NotificationKit uses CSR (Controller-Service-Repository) + Ports & Adapters for maximum reusability and provider interchangeability.** -> **WHY CSR for modules?** Reusable libraries need to be simple, well-documented, and easy to integrate. The 4-layer Clean Architecture is better suited for complex applications, not libraries. +> **WHY CSR + Ports?** Reusable notification libraries must support multiple providers without coupling business logic to any specific SDK. Ports (interfaces) in `core/` define the contracts; adapters in `infra/` implement them. Apps choose which adapters to wire. ``` src/ - โ”œโ”€โ”€ index.ts # PUBLIC API exports - โ”œโ”€โ”€ {module-name}.module.ts # NestJS module definition + โ”œโ”€โ”€ index.ts # PUBLIC API โ€” all exports go through here โ”‚ - โ”œโ”€โ”€ controllers/ # HTTP Layer - โ”‚ โ””โ”€โ”€ example.controller.ts + โ”œโ”€โ”€ core/ # โœ… Framework-FREE (no NestJS imports) + โ”‚ โ”œโ”€โ”€ index.ts + โ”‚ โ”œโ”€โ”€ types.ts # Domain entities & enums + โ”‚ โ”œโ”€โ”€ dtos/ # Input/output contracts (Zod-validated) + โ”‚ โ”œโ”€โ”€ ports/ # Abstractions (interfaces the infra implements) + โ”‚ โ”‚ โ”œโ”€โ”€ notification-sender.port.ts # INotificationSender + โ”‚ โ”‚ โ”œโ”€โ”€ notification-repository.port.ts # INotificationRepository + โ”‚ โ”‚ โ””โ”€โ”€ (template, event, id, datetime ports) + โ”‚ โ”œโ”€โ”€ errors/ # Domain errors + โ”‚ โ””โ”€โ”€ notification.service.ts # Core orchestration logic (framework-free) โ”‚ - โ”œโ”€โ”€ services/ # Business Logic - โ”‚ โ””โ”€โ”€ example.service.ts + โ”œโ”€โ”€ infra/ # Concrete adapter implementations + โ”‚ โ”œโ”€โ”€ index.ts + โ”‚ โ”œโ”€โ”€ senders/ # Channel sender adapters + โ”‚ โ”‚ โ”œโ”€โ”€ email/ # Nodemailer adapter + โ”‚ โ”‚ โ”œโ”€โ”€ sms/ # Twilio / Vonage / AWS SNS adapters + โ”‚ โ”‚ โ”œโ”€โ”€ push/ # Firebase adapter + โ”‚ โ”‚ โ”œโ”€โ”€ in-app/ # In-app adapter + โ”‚ โ”‚ โ””โ”€โ”€ webhook/ # Outbound webhook adapter + โ”‚ โ”œโ”€โ”€ repositories/ # Persistence adapters + โ”‚ โ”‚ โ”œโ”€โ”€ mongodb/ # Mongoose adapter + โ”‚ โ”‚ โ””โ”€โ”€ in-memory/ # In-memory adapter (testing / simple usage) + โ”‚ โ””โ”€โ”€ providers/ # Utility adapters + โ”‚ โ”œโ”€โ”€ id-generator/ # nanoid adapter + โ”‚ โ”œโ”€โ”€ datetime/ # Date/time utilities + โ”‚ โ”œโ”€โ”€ template/ # Handlebars adapter + โ”‚ โ””โ”€โ”€ events/ # Event bus adapter โ”‚ - โ”œโ”€โ”€ entities/ # Domain Models - โ”‚ โ””โ”€โ”€ example.entity.ts - โ”‚ - โ”œโ”€โ”€ repositories/ # Data Access - โ”‚ โ””โ”€โ”€ example.repository.ts - โ”‚ - โ”œโ”€โ”€ guards/ # Auth Guards - โ”‚ โ””โ”€โ”€ example.guard.ts - โ”‚ - โ”œโ”€โ”€ decorators/ # Custom Decorators - โ”‚ โ””โ”€โ”€ example.decorator.ts - โ”‚ - โ”œโ”€โ”€ dto/ # Data Transfer Objects - โ”‚ โ””โ”€โ”€ example.dto.ts - โ”‚ - โ”œโ”€โ”€ filters/ # Exception Filters - โ”œโ”€โ”€ middleware/ # Middleware - โ”œโ”€โ”€ config/ # Configuration - โ””โ”€โ”€ utils/ # Utilities + โ””โ”€โ”€ nest/ # NestJS integration layer + โ”œโ”€โ”€ index.ts + โ”œโ”€โ”€ module.ts # NotificationKitModule + โ”œโ”€โ”€ interfaces.ts # NotificationKitModuleOptions, AsyncOptions, Factory + โ”œโ”€โ”€ constants.ts # NOTIFICATION_KIT_OPTIONS token + โ”œโ”€โ”€ providers.ts # createNotificationKitProviders() factory + โ””โ”€โ”€ controllers/ + โ”œโ”€โ”€ notification.controller.ts # REST API (enable via enableRestApi) + โ””โ”€โ”€ webhook.controller.ts # Inbound webhooks (enable via enableWebhooks) ``` **Responsibility Layers:** -| Layer | Responsibility | Examples | -| ---------------- | ---------------------------------------- | ----------------------- | -| **Controllers** | HTTP handling, route definition | `example.controller.ts` | -| **Services** | Business logic, orchestration | `example.service.ts` | -| **Entities** | Domain models (Mongoose/TypeORM schemas) | `example.entity.ts` | -| **Repositories** | Data access, database queries | `example.repository.ts` | -| **Guards** | Authentication/Authorization | `jwt-auth.guard.ts` | -| **Decorators** | Parameter extraction, metadata | `@CurrentUser()` | -| **DTOs** | Input validation, API contracts | `create-example.dto.ts` | +| Layer | Responsibility | Examples | +| ----------------- | ---------------------------------------------------------- | ----------------------------------------------------------- | +| **Controllers** | HTTP handling, REST API, inbound webhook receivers | `NotificationController`, `WebhookController` | +| **Core Service** | Orchestration, channel routing, retry, status lifecycle | `notification.service.ts` | +| **DTOs** | Input validation, API contracts (Zod) | `SendNotificationDto`, `NotificationQueryDto` | +| **Ports** | Abstractions โ€” what `core/` depends on | `INotificationSender`, `INotificationRepository` | +| **Senders** | Channel delivery โ€” implement `INotificationSender` | `EmailSender`, `SmsSender`, `PushSender` | +| **Repositories** | Persistence โ€” implement `INotificationRepository` | `MongoNotificationRepository`, `InMemoryRepository` | +| **Providers** | Cross-cutting utilities | `HandlebarsTemplateProvider`, `NanoidGenerator` | +| **Domain Types** | Entities, enums, value objects (immutable, framework-free) | `Notification`, `NotificationChannel`, `NotificationStatus` | +| **Domain Errors** | Typed, named error classes | `ChannelNotConfiguredError`, `NotificationNotFoundError` | + +### Layer Import Rules โ€” STRICTLY ENFORCED + +| Layer | Can import from | Cannot import from | +| ------- | ---------------------- | ------------------ | +| `core` | Nothing internal | `infra`, `nest` | +| `infra` | `core` (ports & types) | `nest` | +| `nest` | `core`, `infra` | โ€” | + +> **The golden rule**: `core/` must compile with zero NestJS or provider SDK imports. If you're adding a NestJS decorator or importing `nodemailer` inside `core/`, it's in the wrong layer. + +--- + +## ๐Ÿ“ Naming Conventions + +### Files + +**Pattern**: `kebab-case` + suffix + +| Type | Example | Directory | +| ---------------- | ---------------------------------- | --------------------------------- | +| Module | `module.ts` | `src/nest/` | +| Controller | `notification.controller.ts` | `src/nest/controllers/` | +| Core Service | `notification.service.ts` | `src/core/` | +| Port interface | `notification-sender.port.ts` | `src/core/ports/` | +| DTO | `send-notification.dto.ts` | `src/core/dtos/` | +| Domain Error | `notification-not-found.error.ts` | `src/core/errors/` | +| Sender adapter | `email.sender.ts` | `src/infra/senders/email/` | +| Repository | `mongo-notification.repository.ts` | `src/infra/repositories/mongodb/` | +| Utility provider | `handlebars-template.provider.ts` | `src/infra/providers/template/` | +| Constants | `constants.ts` | `src/nest/` | + +### Code Naming + +- **Classes & Interfaces**: `PascalCase` โ†’ `NotificationService`, `INotificationSender`, `SendNotificationDto` +- **Variables & functions**: `camelCase` โ†’ `sendNotification`, `buildProviders` +- **Constants / DI tokens**: `UPPER_SNAKE_CASE` โ†’ `NOTIFICATION_KIT_OPTIONS`, `NOTIFICATION_SENDER`, `NOTIFICATION_REPOSITORY` +- **Enums**: Name `PascalCase`, values match protocol strings + +```typescript +// โœ… Correct enum definitions +enum NotificationChannel { + EMAIL = "email", + SMS = "sms", + PUSH = "push", + IN_APP = "in_app", + WEBHOOK = "webhook", +} -**Module Exports (Public API):** +enum NotificationStatus { + PENDING = "pending", + QUEUED = "queued", + SENDING = "sending", + SENT = "sent", + DELIVERED = "delivered", + FAILED = "failed", + CANCELLED = "cancelled", +} +``` + +### Path Aliases (`tsconfig.json`) ```typescript -// src/index.ts - Only export what apps need to consume -export { ExampleModule } from "./example.module"; +"@/*" โ†’ "src/*" +"@core/*" โ†’ "src/core/*" +"@infra/*" โ†’ "src/infra/*" +"@nest/*" โ†’ "src/nest/*" +``` + +Use aliases for cleaner imports: -// Services (main API) -export { ExampleService } from "./services/example.service"; +```typescript +import { NotificationService } from "@core/notification.service"; +import { INotificationSender } from "@core/ports/notification-sender.port"; +import { SendNotificationDto } from "@core/dtos/send-notification.dto"; +import { EmailSender } from "@infra/senders/email/email.sender"; +``` -// DTOs (public contracts) -export { CreateExampleDto, UpdateExampleDto } from "./dto"; +--- -// Guards (for protecting routes) -export { ExampleGuard } from "./guards/example.guard"; +## ๐Ÿ“ฆ Public API โ€” `src/index.ts` -// Decorators (for DI and metadata) -export { ExampleDecorator } from "./decorators/example.decorator"; +```typescript +// โœ… All exports go through here โ€” never import from deep paths in consuming apps +export * from "./core"; // Types, DTOs, ports, errors, NotificationService +export * from "./infra"; // Senders, repositories, utility providers +export * from "./nest"; // NotificationKitModule, interfaces, constants +``` -// Types & Interfaces (for TypeScript typing) -export type { ExampleOptions, ExampleResult } from "./types"; +**What consuming apps should use:** -// โŒ NEVER export entities or repositories -// export { Example } from './entities/example.entity'; // FORBIDDEN -// export { ExampleRepository } from './repositories/example.repository'; // FORBIDDEN +```typescript +import { + NotificationKitModule, + NotificationService, + SendNotificationDto, + NotificationChannel, + NotificationStatus, + NotificationPriority, + type Notification, + type NotificationResult, + type INotificationSender, // for custom adapter implementations + type INotificationRepository, // for custom adapter implementations +} from "@ciscode/notification-kit"; ``` -**Rationale:** +**โŒ NEVER export:** -- **Entities** = internal implementation details (can change) -- **Repositories** = internal data access (apps shouldn't depend on it) -- **DTOs** = stable public contracts (apps depend on these) -- **Services** = public API (apps use methods, not internals) +- Internal provider wiring (`createNotificationKitProviders` internals) +- Raw SDK instances (Nodemailer transporter, Twilio client, Firebase app) +- Mongoose schema definitions (infrastructure details) --- -## ๐Ÿ“ Naming Conventions +## โš™๏ธ Module Registration -### Files +### `register()` โ€” sync -**Pattern**: `kebab-case` + suffix +```typescript +NotificationKitModule.register({ + channels: { + email: { + provider: "nodemailer", + from: "no-reply@ciscode.com", + smtp: { host: "smtp.example.com", port: 587, auth: { user: "...", pass: "..." } }, + }, + sms: { + provider: "twilio", + accountSid: process.env.TWILIO_SID, + authToken: process.env.TWILIO_TOKEN, + from: process.env.TWILIO_FROM, + }, + push: { + provider: "firebase", + serviceAccount: JSON.parse(process.env.FIREBASE_SA!), + }, + }, + repository: { type: "mongodb", uri: process.env.MONGO_URI }, + templates: { engine: "handlebars", dir: "./templates" }, + enableRestApi: true, // default: true + enableWebhooks: true, // default: true + retries: { max: 3, backoff: "exponential" }, +}); +``` -| Type | Example | Directory | -| ---------- | --------------------------- | --------------- | -| Controller | `example.controller.ts` | `controllers/` | -| Service | `example.service.ts` | `services/` | -| Entity | `example.entity.ts` | `entities/` | -| Repository | `example.repository.ts` | `repositories/` | -| DTO | `create-example.dto.ts` | `dto/` | -| Guard | `jwt-auth.guard.ts` | `guards/` | -| Decorator | `current-user.decorator.ts` | `decorators/` | -| Filter | `http-exception.filter.ts` | `filters/` | -| Middleware | `logger.middleware.ts` | `middleware/` | -| Utility | `validation.utils.ts` | `utils/` | -| Config | `jwt.config.ts` | `config/` | +### `registerAsync()` โ€” with ConfigService -### Code Naming +```typescript +NotificationKitModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + channels: { + email: { provider: "nodemailer", from: config.get("EMAIL_FROM") /* ... */ }, + sms: { provider: config.get("SMS_PROVIDER") /* ... */ }, + }, + repository: { type: config.get("DB_TYPE"), uri: config.get("MONGO_URI") }, + enableRestApi: config.get("NOTIF_REST_API", true), + enableWebhooks: config.get("NOTIF_WEBHOOKS", true), + }), +}); +``` -- **Classes & Interfaces**: `PascalCase` โ†’ `ExampleController`, `CreateExampleDto` -- **Variables & Functions**: `camelCase` โ†’ `getUserById`, `exampleList` -- **Constants**: `UPPER_SNAKE_CASE` โ†’ `DEFAULT_TIMEOUT`, `MAX_RETRIES` -- **Enums**: Name `PascalCase`, values `UPPER_SNAKE_CASE` +### `registerAsync()` โ€” with `useClass` / `useExisting` ```typescript -enum ExampleStatus { - ACTIVE = "ACTIVE", - INACTIVE = "INACTIVE", -} +// useClass โ€” module instantiates the factory +NotificationKitModule.registerAsync({ useClass: NotificationKitConfigService }); + +// useExisting โ€” reuse an already-provided factory +NotificationKitModule.registerAsync({ useExisting: NotificationKitConfigService }); +``` + +> **Rule**: All channel credentials must come from env vars or `ConfigService` โ€” never hardcoded in source. Validate all options with Zod at module startup. + +> **Controller limitation**: Controllers (`enableRestApi`, `enableWebhooks`) cannot be conditionally mounted in `registerAsync` mode and are excluded. Document this clearly when advising consumers. + +--- + +## ๐Ÿงฉ Core Components + +### `NotificationService` (core โ€” framework-free) + +The single orchestration point. Inject this in consuming apps. Never inject raw senders or repositories. + +```typescript +// Inject in your NestJS service +constructor(private readonly notifications: NotificationService) {} + +// Send a single notification +const result = await this.notifications.send({ + channel: NotificationChannel.EMAIL, + recipient: { id: 'user-1', email: 'user@example.com' }, + content: { title: 'Welcome', body: 'Hello!', templateId: 'welcome' }, + priority: NotificationPriority.HIGH, +}); + +// Batch send +const results = await this.notifications.sendBatch([...]); +``` + +**Public methods:** + +```typescript +send(dto: SendNotificationDto): Promise +sendBatch(dtos: SendNotificationDto[]): Promise +getById(id: string): Promise +getByRecipient(recipientId: string, filters?): Promise +cancel(id: string): Promise +retry(id: string): Promise ``` -### Path Aliases +### `INotificationSender` Port -Configured in `tsconfig.json`: +All channel senders implement this port. To add a new channel or provider, implement this interface in `infra/senders//`: ```typescript -"@/*" โ†’ "src/*" -"@controllers/*" โ†’ "src/controllers/*" -"@services/*" โ†’ "src/services/*" -"@entities/*" โ†’ "src/entities/*" -"@repos/*" โ†’ "src/repositories/*" -"@dtos/*" โ†’ "src/dto/*" -"@guards/*" โ†’ "src/guards/*" -"@decorators/*" โ†’ "src/decorators/*" -"@config/*" โ†’ "src/config/*" -"@utils/*" โ†’ "src/utils/*" +// core/ports/notification-sender.port.ts +interface INotificationSender { + readonly channel: NotificationChannel; + send(notification: Notification): Promise; + isConfigured(): boolean; +} ``` -Use aliases for cleaner imports: +### `INotificationRepository` Port + +All persistence adapters implement this. Apps never depend on Mongoose schemas directly: ```typescript -import { CreateExampleDto } from "@dtos/create-example.dto"; -import { ExampleService } from "@services/example.service"; -import { Example } from "@entities/example.entity"; +// core/ports/notification-repository.port.ts +interface INotificationRepository { + save(notification: Notification): Promise; + findById(id: string): Promise; + findByRecipient(recipientId: string, filters?): Promise; + updateStatus(id: string, status: NotificationStatus, extra?): Promise; + delete(id: string): Promise; +} ``` +### `NotificationController` (REST API) + +Mounted when `enableRestApi: true`. Provides: + +| Method | Path | Description | +| ------ | ------------------------------ | ------------------------------ | +| `POST` | `/notifications` | Send a notification | +| `POST` | `/notifications/batch` | Send multiple notifications | +| `GET` | `/notifications/:id` | Get notification by ID | +| `GET` | `/notifications/recipient/:id` | Get notifications by recipient | +| `POST` | `/notifications/:id/cancel` | Cancel a pending notification | +| `POST` | `/notifications/:id/retry` | Retry a failed notification | + +### `WebhookController` + +Mounted when `enableWebhooks: true`. Receives delivery status callbacks from providers (Twilio, Firebase, etc.) and updates notification status accordingly. Must verify provider-specific signatures. + +--- + +## ๐Ÿ”Œ Optional Provider Peer Dependencies + +All channel provider SDKs are **optional peer dependencies**. Only install what you use: + +| Channel | Provider | Peer dep | Install when... | +| ------- | ----------- | --------------------- | ---------------------------- | +| Email | Nodemailer | `nodemailer` | Using email channel | +| SMS | Twilio | `twilio` | Using Twilio SMS | +| SMS | Vonage | `@vonage/server-sdk` | Using Vonage SMS | +| SMS | AWS SNS | `@aws-sdk/client-sns` | Using AWS SNS SMS | +| Push | Firebase | `firebase-admin` | Using push notifications | +| Any | Persistence | `mongoose` | Using MongoDB repository | +| Any | Templates | `handlebars` | Using template rendering | +| Any | ID gen | `nanoid` | Using the default ID adapter | + +> **Rule for adding a new provider**: implement `INotificationSender` in `infra/senders//.sender.ts`, guard the import with a clear startup error if the peer dep is missing, and document the peer dep in JSDoc and README. + --- ## ๐Ÿงช Testing - RIGOROUS for Modules ### Coverage Target: 80%+ -**Unit Tests - MANDATORY:** +**Unit Tests โ€” MANDATORY:** -- โœ… All services (business logic) -- โœ… All utilities and helpers -- โœ… Guards and decorators -- โœ… Repository methods +- โœ… `core/notification.service.ts` โ€” channel routing, retry logic, status lifecycle, error handling +- โœ… All DTOs โ€” Zod schema validation, edge cases, invalid inputs +- โœ… All domain errors โ€” correct messages, inheritance +- โœ… Each sender adapter โ€” success path, failure path, `isConfigured()` guard +- โœ… Each repository adapter โ€” CRUD operations, query filters +- โœ… Template provider โ€” variable substitution, missing template errors +- โœ… ID generator and datetime providers **Integration Tests:** -- โœ… Controllers (full request/response) -- โœ… Module initialization -- โœ… Database operations (with test DB or mocks) +- โœ… `NotificationKitModule.register()` โ€” correct provider wiring per channel config +- โœ… `NotificationKitModule.registerAsync()` โ€” factory injection, full options resolved +- โœ… `NotificationController` โ€” full HTTP request/response lifecycle +- โœ… `WebhookController` โ€” provider callback โ†’ status update flow +- โœ… MongoDB repository โ€” real schema operations (with test DB or `mongodb-memory-server`) **E2E Tests:** -- โœ… Complete flows (critical user paths) +- โœ… Send notification โ†’ delivery โ†’ status update (per channel) +- โœ… Retry flow (failure โ†’ retry โ†’ success) +- โœ… Scheduled notification lifecycle -**Test file location:** +**Test file location:** same directory as source (`*.spec.ts`) ``` -src/ - โ””โ”€โ”€ services/ - โ”œโ”€โ”€ example.service.ts - โ””โ”€โ”€ example.service.spec.ts โ† Same directory +src/core/ + โ”œโ”€โ”€ notification.service.ts + โ””โ”€โ”€ notification.service.spec.ts + +src/infra/senders/email/ + โ”œโ”€โ”€ email.sender.ts + โ””โ”€โ”€ email.sender.spec.ts +``` + +**Mocking senders and repositories in unit tests:** + +```typescript +const mockSender: INotificationSender = { + channel: NotificationChannel.EMAIL, + send: jest.fn().mockResolvedValue({ success: true, notificationId: "n1" }), + isConfigured: jest.fn().mockReturnValue(true), +}; + +const mockRepository: INotificationRepository = { + save: jest.fn(), + findById: jest.fn(), + findByRecipient: jest.fn(), + updateStatus: jest.fn(), + delete: jest.fn(), +}; ``` **Jest Configuration:** @@ -203,9 +430,9 @@ src/ ```javascript coverageThreshold: { global: { - branches: 80, - functions: 80, - lines: 80, + branches: 80, + functions: 80, + lines: 80, statements: 80, }, } @@ -219,32 +446,49 @@ coverageThreshold: { ````typescript /** - * Creates a new example record - * @param data - The example data to create - * @returns The created example with generated ID - * @throws {BadRequestException} If data is invalid + * Sends a notification through the specified channel. + * Routes to the appropriate sender adapter, persists the notification, + * and updates its status throughout the delivery lifecycle. + * + * @param dto - Validated send notification payload + * @returns Result containing success status and provider message ID + * + * @throws {ChannelNotConfiguredError} If the channel has no configured provider + * @throws {RecipientMissingFieldError} If the recipient is missing required fields for the channel + * * @example * ```typescript - * const example = await service.create({ name: 'Test' }); + * const result = await notificationService.send({ + * channel: NotificationChannel.EMAIL, + * recipient: { id: 'user-1', email: 'user@example.com' }, + * content: { title: 'Welcome', body: 'Hello!' }, + * priority: NotificationPriority.NORMAL, + * }); * ``` */ -async create(data: CreateExampleDto): Promise +async send(dto: SendNotificationDto): Promise ```` **Required for:** -- All public functions/methods -- All exported classes -- All DTOs (with property descriptions) +- All public methods on `NotificationService` +- All port interfaces in `core/ports/` +- All exported DTOs (with per-property descriptions) +- All exported domain error classes +- Both `register()` and `registerAsync()` on `NotificationKitModule` +- All sender adapters' `send()` methods (document provider-specific behavior and peer dep) -### Swagger/OpenAPI - Always on controllers: +### Swagger/OpenAPI โ€” ALWAYS on controllers: ```typescript -@ApiOperation({ summary: 'Create new example' }) -@ApiResponse({ status: 201, description: 'Created successfully', type: ExampleDto }) -@ApiResponse({ status: 400, description: 'Invalid input' }) +@ApiTags('notifications') +@ApiOperation({ summary: 'Send a notification' }) +@ApiBody({ type: SendNotificationDto }) +@ApiResponse({ status: 201, description: 'Notification queued successfully', type: NotificationResultDto }) +@ApiResponse({ status: 400, description: 'Invalid input or missing recipient field' }) +@ApiResponse({ status: 422, description: 'Channel not configured' }) @Post() -async create(@Body() dto: CreateExampleDto) { } +async send(@Body() dto: SendNotificationDto): Promise {} ``` --- @@ -253,50 +497,51 @@ async create(@Body() dto: CreateExampleDto) { } ### 1. Exportability -**Export ONLY public API (Services + DTOs + Guards + Decorators):** +**Export ONLY public API:** ```typescript -// src/index.ts - Public API -export { ExampleModule } from "./example.module"; -export { ExampleService } from "./services/example.service"; -export { CreateExampleDto, UpdateExampleDto } from "./dto"; -export { ExampleGuard } from "./guards/example.guard"; -export { ExampleDecorator } from "./decorators/example.decorator"; -export type { ExampleOptions } from "./types"; +// src/index.ts +export * from "./core"; // Types, DTOs, ports, errors, NotificationService +export * from "./infra"; // Senders, repositories, providers +export * from "./nest"; // NotificationKitModule, interfaces ``` **โŒ NEVER export:** -- Entities (internal domain models) -- Repositories (infrastructure details) +- Raw SDK clients (Nodemailer transporter, Twilio client instances) +- Internal `createNotificationKitProviders()` wiring details +- Mongoose schema definitions ### 2. Configuration -**Flexible module registration:** +**All three async patterns supported:** ```typescript @Module({}) -export class ExampleModule { - static forRoot(options: ExampleModuleOptions): DynamicModule { - return { - module: ExampleModule, - providers: [{ provide: "EXAMPLE_OPTIONS", useValue: options }, ExampleService], - exports: [ExampleService], - }; +export class NotificationKitModule { + static register(options: NotificationKitModuleOptions): DynamicModule { + /* ... */ } - - static forRootAsync(options: ExampleModuleAsyncOptions): DynamicModule { - // Async configuration + static registerAsync(options: NotificationKitModuleAsyncOptions): DynamicModule { + // supports useFactory, useClass, useExisting } } ``` +**Controllers are opt-out, not opt-in:** + +```typescript +// Both default to true โ€” apps must explicitly disable +NotificationKitModule.register({ enableRestApi: false, enableWebhooks: false }); +``` + ### 3. Zero Business Logic Coupling -- No hardcoded business rules -- Configurable behavior via options -- Database-agnostic (if applicable) -- Apps provide their own connections +- No hardcoded recipients, templates, credentials, or channel preferences +- All provider credentials from options (never from `process.env` directly inside the module) +- Channel senders are stateless โ€” no shared mutable state between requests +- Repository is swappable โ€” core service depends only on `INotificationRepository` +- Apps bring their own Mongoose connection โ€” this module never creates its own DB connection --- @@ -307,34 +552,30 @@ export class ExampleModule { **1. Branch Creation:** ```bash -feature/MODULE-123-add-feature -bugfix/MODULE-456-fix-issue -refactor/MODULE-789-improve-code +feature/NOTIF-123-add-vonage-sms-sender +bugfix/NOTIF-456-fix-firebase-retry-on-token-expiry +refactor/NOTIF-789-extract-retry-logic-to-core ``` **2. Task Documentation:** Create task file at branch start: ``` -docs/tasks/active/MODULE-123-add-feature.md +docs/tasks/active/NOTIF-123-add-vonage-sms-sender.md ``` **3. On Release:** Move to archive: ``` -docs/tasks/archive/by-release/v2.0.0/MODULE-123-add-feature.md +docs/tasks/archive/by-release/v1.0.0/NOTIF-123-add-vonage-sms-sender.md ``` ### Development Workflow -**Simple changes**: +**Simple changes**: Read context โ†’ Implement โ†’ Update docs โ†’ **Create changeset** -- Read context โ†’ Implement โ†’ Update docs โ†’ **Create changeset** - -**Complex changes**: - -- Read context โ†’ Discuss approach โ†’ Implement โ†’ Update docs โ†’ **Create changeset** +**Complex changes**: Read context โ†’ Discuss approach โ†’ Implement โ†’ Update docs โ†’ **Create changeset** **When blocked**: @@ -347,23 +588,28 @@ docs/tasks/archive/by-release/v2.0.0/MODULE-123-add-feature.md ### Semantic Versioning (Strict) -**MAJOR** (x.0.0) - Breaking changes: +**MAJOR** (x.0.0) โ€” Breaking changes: -- Changed function signatures -- Removed public methods -- Changed DTOs structure -- Changed module configuration +- Changed `NotificationService` public method signatures +- Removed or renamed fields in `SendNotificationDto` or `Notification` +- Changed `NotificationKitModuleOptions` required fields +- Renamed `register()` / `registerAsync()` or changed their call signatures +- Changed `INotificationSender` or `INotificationRepository` port contracts +- Removed a supported channel or provider -**MINOR** (0.x.0) - New features: +**MINOR** (0.x.0) โ€” New features: -- New endpoints/methods -- New optional parameters -- New decorators/guards +- New channel support (e.g. WhatsApp sender) +- New optional fields in `NotificationKitModuleOptions` +- New provider for an existing channel (e.g. Vonage alongside Twilio) +- New `NotificationService` methods (additive) +- New exported utilities or decorators -**PATCH** (0.0.x) - Bug fixes: +**PATCH** (0.0.x) โ€” Bug fixes: -- Internal fixes -- Performance improvements +- Provider-specific delivery fix +- Retry backoff correction +- Template rendering edge case - Documentation updates ### Changesets Workflow @@ -376,10 +622,7 @@ npx changeset **When to create a changeset:** -- โœ… New features -- โœ… Bug fixes -- โœ… Breaking changes -- โœ… Performance improvements +- โœ… New features, bug fixes, breaking changes, performance improvements - โŒ Internal refactoring (no user impact) - โŒ Documentation updates only - โŒ Test improvements only @@ -396,10 +639,10 @@ npx changeset ```markdown --- -"@ciscode/example-kit": minor +"@ciscode/notification-kit": minor --- -Added support for custom validators in ExampleService +Added Vonage SMS sender adapter as an alternative to Twilio ``` ### CHANGELOG Required @@ -407,23 +650,22 @@ Added support for custom validators in ExampleService Changesets automatically generates CHANGELOG. For manual additions: ```markdown -# Changelog - -## [2.0.0] - 2026-02-03 +## [1.0.0] - 2026-02-26 ### BREAKING CHANGES -- `create()` now requires `userId` parameter -- Removed deprecated `validateExample()` method +- `NotificationService.send()` now requires `priority` field in `SendNotificationDto` +- Removed `createDefaultNotificationService()` โ€” use `NotificationKitModule.register()` instead ### Added -- New `ExampleGuard` for route protection -- Support for async configuration +- Vonage SMS sender adapter +- `sendBatch()` method on `NotificationService` +- In-memory repository for testing and lightweight usage ### Fixed -- Fixed validation edge case +- Firebase push sender now correctly retries on token expiry (401) ``` --- @@ -432,45 +674,44 @@ Changesets automatically generates CHANGELOG. For manual additions: **ALWAYS:** -- โœ… Input validation on all DTOs (class-validator) -- โœ… JWT secret from env (never hardcoded) -- โœ… Rate limiting on public endpoints -- โœ… No secrets in code -- โœ… Sanitize error messages (no stack traces in production) - -**Example:** +- โœ… Validate all DTOs with Zod at module boundary +- โœ… All provider credentials from env vars โ€” never hardcoded +- โœ… Sanitize notification content before logging โ€” never log full `templateVars` (may contain PII) +- โœ… Webhook endpoints must verify provider signatures (e.g. `X-Twilio-Signature`) +- โœ… Rate-limit the REST API endpoints in production (document this requirement for consumers) +- โœ… Recipient `metadata` must never appear in error messages or stack traces ```typescript -export class CreateExampleDto { - @IsString() - @MinLength(3) - @MaxLength(50) - name: string; - - @IsEmail() - email: string; -} +// โŒ WRONG โ€” logs PII from templateVars +this.logger.error("Template render failed", { notification }); + +// โœ… CORRECT โ€” log only safe identifiers +this.logger.error("Template render failed", { + notificationId: notification.id, + channel: notification.channel, +}); ``` --- -## ๐Ÿšซ Restrictions - Require Approval +## ๐Ÿšซ Restrictions โ€” Require Approval **NEVER without approval:** -- Breaking changes to public API -- Changing exported DTOs/interfaces -- Removing exported functions -- Major dependency upgrades -- Security-related changes +- Breaking changes to `NotificationService` public methods +- Removing or renaming fields in `SendNotificationDto`, `Notification`, or `NotificationResult` +- Changing `INotificationSender` or `INotificationRepository` port contracts +- Removing a supported channel or provider adapter +- Renaming `register()` / `registerAsync()` or their option shapes +- Security-related changes (webhook signature verification, credential handling) **CAN do autonomously:** -- Bug fixes (no breaking changes) -- Internal refactoring -- Adding new features (non-breaking) -- Test improvements -- Documentation updates +- Bug fixes (non-breaking) +- New optional `NotificationKitModuleOptions` fields +- New sender adapter for an existing channel (e.g. AWS SES alongside Nodemailer) +- Internal refactoring within a single layer (no public API or port contract change) +- Test and documentation improvements --- @@ -481,39 +722,42 @@ Before publishing: - [ ] All tests passing (100% of test suite) - [ ] Coverage >= 80% - [ ] No ESLint warnings (`--max-warnings=0`) -- [ ] TypeScript strict mode passing +- [ ] TypeScript strict mode passing (`tsc --noEmit`) +- [ ] `npm run build` succeeds โ€” both `.mjs` and `.cjs` outputs in `dist/` - [ ] All public APIs documented (JSDoc) -- [ ] README updated with examples +- [ ] All new `NotificationKitModuleOptions` fields documented in README +- [ ] Optional peer deps documented (which to install for which channel) - [ ] Changeset created -- [ ] Breaking changes highlighted -- [ ] Integration tested with sample app +- [ ] Breaking changes highlighted in changeset +- [ ] Integration tested via `npm link` in a real consuming NestJS app --- ## ๐Ÿ”„ Development Workflow -### Working on Module: +### Working on the Module: -1. Clone module repo -2. Create branch: `feature/TASK-123-description` from `develop` +1. Clone the repo +2. Create branch: `feature/NOTIF-123-description` from `develop` 3. Implement with tests 4. **Create changeset**: `npx changeset` 5. Verify checklist 6. Create PR โ†’ `develop` -### Testing in App: +### Testing in a Consuming App: ```bash -# In module +# In notification-kit +npm run build npm link -# In app -cd ~/comptaleyes/backend -npm link @ciscode/example-kit +# In your NestJS app +cd ~/ciscode/backend +npm link @ciscode/notification-kit # Develop and test # Unlink when done -npm unlink @ciscode/example-kit +npm unlink @ciscode/notification-kit ``` --- @@ -523,18 +767,35 @@ npm unlink @ciscode/example-kit - ESLint `--max-warnings=0` - Prettier formatting - TypeScript strict mode -- FP for logic, OOP for structure -- Dependency injection via constructor - -**Example:** +- Pure functions in `core/` (no side effects, no SDK calls) +- OOP classes for NestJS providers and sender/repository adapters +- Dependency injection via constructor โ€” never property-based `@Inject()` +- Sender adapters are stateless โ€” no mutable instance variables after construction ```typescript +// โœ… Correct โ€” constructor injection, stateless sender @Injectable() -export class ExampleService { +export class EmailSender implements INotificationSender { + readonly channel = NotificationChannel.EMAIL; + constructor( - private readonly repo: ExampleRepository, - private readonly logger: LoggerService, + @Inject(NOTIFICATION_KIT_OPTIONS) + private readonly options: NotificationKitModuleOptions, ) {} + + async send(notification: Notification): Promise { + /* ... */ + } + isConfigured(): boolean { + return !!this.options.channels?.email; + } +} + +// โŒ Wrong โ€” property injection, mutable state +@Injectable() +export class EmailSender { + @Inject(NOTIFICATION_KIT_OPTIONS) private options: NotificationKitModuleOptions; + private transporter: any; // mutated after construction โ† FORBIDDEN } ``` @@ -542,24 +803,34 @@ export class ExampleService { ## ๐Ÿ› Error Handling -**Custom domain errors:** +**Custom domain errors โ€” ALWAYS in `core/errors/`:** ```typescript -export class ExampleNotFoundError extends Error { +export class ChannelNotConfiguredError extends Error { + constructor(channel: NotificationChannel) { + super( + `Channel "${channel}" is not configured. Did you pass options for it in NotificationKitModule.register()?`, + ); + this.name = "ChannelNotConfiguredError"; + } +} + +export class NotificationNotFoundError extends Error { constructor(id: string) { - super(`Example ${id} not found`); - this.name = "ExampleNotFoundError"; + super(`Notification "${id}" not found`); + this.name = "NotificationNotFoundError"; } } ``` -**Structured logging:** +**Structured logging โ€” safe identifiers only:** ```typescript -this.logger.error("Operation failed", { - exampleId: id, - reason: "validation_error", - timestamp: new Date().toISOString(), +this.logger.error("Notification delivery failed", { + notificationId: notification.id, + channel: notification.channel, + provider: "twilio", + attempt: notification.retryCount, }); ``` @@ -568,16 +839,18 @@ this.logger.error("Operation failed", { ```typescript // โŒ WRONG try { - await operation(); -} catch (error) { - // Silent failure + await sender.send(notification); +} catch { + // silent } // โœ… CORRECT try { - await operation(); + await sender.send(notification); } catch (error) { - this.logger.error("Operation failed", { error }); + await this.repository.updateStatus(notification.id, NotificationStatus.FAILED, { + error: (error as Error).message, + }); throw error; } ``` @@ -587,9 +860,10 @@ try { ## ๐Ÿ’ฌ Communication Style - Brief and direct -- Focus on results -- Module-specific context -- Highlight breaking changes immediately +- Reference the correct layer (`core`, `infra`, `nest`) when discussing changes +- Always name the channel and provider when discussing sender-related changes +- Flag breaking changes immediately โ€” even suspected ones +- This module is consumed by multiple services โ€” when in doubt about impact, ask --- @@ -602,12 +876,28 @@ try { 3. Complete documentation 4. Strict versioning 5. Breaking changes = MAJOR bump + changeset -6. Zero app coupling -7. Configurable behavior - -**When in doubt:** Ask, don't assume. Modules impact multiple projects. +6. Zero app coupling โ€” no hardcoded credentials, recipients, or templates +7. Configurable behavior via `NotificationKitModuleOptions` + +**Layer ownership โ€” quick reference:** + +| Concern | Owner | +| ---------------------------- | ---------------------------------- | +| Domain types & enums | `src/core/types.ts` | +| DTOs & Zod validation | `src/core/dtos/` | +| Port interfaces | `src/core/ports/` | +| Orchestration logic | `src/core/notification.service.ts` | +| Domain errors | `src/core/errors/` | +| Channel sender adapters | `src/infra/senders//` | +| Persistence adapters | `src/infra/repositories/` | +| Utility adapters | `src/infra/providers/` | +| NestJS DI, module, providers | `src/nest/` | +| REST API & webhook endpoints | `src/nest/controllers/` | +| All public exports | `src/index.ts` | + +**When in doubt:** Ask, don't assume. This module delivers notifications across production services. --- -_Last Updated: February 3, 2026_ -_Version: 2.0.0_ +_Last Updated: February 26, 2026_ +_Version: 1.0.0_ diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 1a05af2..45b37b3 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -2,7 +2,7 @@ name: CI - Release Check on: pull_request: - branches: [master] + branches: [master, main] workflow_dispatch: inputs: sonar: @@ -28,7 +28,7 @@ jobs: env: SONAR_HOST_URL: "https://sonarcloud.io" SONAR_ORGANIZATION: "ciscode" - SONAR_PROJECT_KEY: "CISCODE-MA_LoggingKit" + SONAR_PROJECT_KEY: "CISCODE-MA_NotificationKit" steps: - name: Checkout @@ -62,7 +62,7 @@ jobs: - name: SonarCloud Scan if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }} - uses: SonarSource/sonarqube-scan-action@v6 + uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 # v6 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }} @@ -76,7 +76,7 @@ jobs: - name: SonarCloud Quality Gate if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }} - uses: SonarSource/sonarqube-quality-gate-action@v1 + uses: SonarSource/sonarqube-quality-gate-action@d304d050d930b02a896b0f85935344f023928496 # v1 timeout-minutes: 10 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/package-lock.json b/package-lock.json index 4ab5e5f..734bbbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^16.2.7", + "mongoose": "^9.2.3", "prettier": "^3.4.2", "ts-jest": "^29.2.5", "tsup": "^8.3.5", @@ -35,6 +36,32 @@ "@nestjs/core": "^10 || ^11", "reflect-metadata": "^0.2.2", "rxjs": "^7" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-sns": { + "optional": true + }, + "@vonage/server-sdk": { + "optional": true + }, + "firebase-admin": { + "optional": true + }, + "handlebars": { + "optional": true + }, + "mongoose": { + "optional": true + }, + "nanoid": { + "optional": true + }, + "nodemailer": { + "optional": true + }, + "twilio": { + "optional": true + } } }, "node_modules/@babel/code-frame": { @@ -2455,6 +2482,16 @@ "node": ">= 4.0.0" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", + "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@nestjs/common": { "version": "11.1.10", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.10.tgz", @@ -3383,6 +3420,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -4257,6 +4311,16 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7940,6 +8004,16 @@ "node": ">=6" } }, + "node_modules/kareem": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz", + "integrity": "sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8263,6 +8337,13 @@ "node": ">= 0.8" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "dev": true, + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -8423,6 +8504,109 @@ "ufo": "^1.6.1" } }, + "node_modules/mongodb": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.0.0.tgz", + "integrity": "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.0.0", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongoose": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.2.3.tgz", + "integrity": "sha512-4XFKKkXUOsdY+p07eJyio4mk0rzZOT4n5r5tLqZNeRZ/IsS68vS8Szw8uShX4p7S687XGGc+MFAp+6K1OIN0aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "kareem": "3.2.0", + "mongodb": "~7.0", + "mpath": "0.9.0", + "mquery": "6.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", + "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -10011,6 +10195,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -10087,6 +10278,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/spawndamnit": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz", @@ -10544,6 +10745,19 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -11120,6 +11334,30 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index bd323e2..d516f7a 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,32 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7" }, + "peerDependenciesMeta": { + "nodemailer": { + "optional": true + }, + "twilio": { + "optional": true + }, + "@aws-sdk/client-sns": { + "optional": true + }, + "@vonage/server-sdk": { + "optional": true + }, + "firebase-admin": { + "optional": true + }, + "mongoose": { + "optional": true + }, + "handlebars": { + "optional": true + }, + "nanoid": { + "optional": true + } + }, "dependencies": { "zod": "^3.24.1" }, @@ -63,6 +89,7 @@ "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^16.2.7", + "mongoose": "^9.2.3", "prettier": "^3.4.2", "ts-jest": "^29.2.5", "tsup": "^8.3.5", diff --git a/src/index.ts b/src/index.ts index 57b076b..f39fa91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,8 @@ +// Core domain layer export * from "./core"; + +// Infrastructure layer +export * from "./infra"; + +// NestJS integration layer export * from "./nest"; diff --git a/src/infra/README.md b/src/infra/README.md index 6752dbd..db3da1f 100644 --- a/src/infra/README.md +++ b/src/infra/README.md @@ -1,5 +1,374 @@ -## Infra layer: external adapters and implementations. +# Infrastructure Layer -- May depend on `core/` -- Must not be imported by consumers directly -- Expose anything public via `src/index.ts` only +This directory contains concrete implementations of the core notification interfaces. + +## ๐Ÿ“ Structure + +``` +infra/ +โ”œโ”€โ”€ senders/ # Notification channel senders +โ”‚ โ”œโ”€โ”€ email/ # Email providers +โ”‚ โ”œโ”€โ”€ sms/ # SMS providers +โ”‚ โ””โ”€โ”€ push/ # Push notification providers +โ”œโ”€โ”€ repositories/ # Data persistence +โ”‚ โ”œโ”€โ”€ mongoose/ # MongoDB with Mongoose +โ”‚ โ””โ”€โ”€ in-memory/ # In-memory (testing) +โ””โ”€โ”€ providers/ # Utility providers + โ”œโ”€โ”€ id-generator.provider.ts + โ”œโ”€โ”€ datetime.provider.ts + โ”œโ”€โ”€ template.provider.ts + โ””โ”€โ”€ event-emitter.provider.ts +``` + +## ๐Ÿ”Œ Email Senders + +### Nodemailer (SMTP) + +Works with any SMTP provider (Gmail, SendGrid, AWS SES via SMTP, etc.) + +```typescript +import { NodemailerSender } from "@ciscode/notification-kit/infra"; + +const emailSender = new NodemailerSender({ + host: "smtp.gmail.com", + port: 587, + secure: false, + auth: { + user: "your-email@gmail.com", + pass: "your-app-password", + }, + from: "noreply@example.com", + fromName: "My App", +}); +``` + +**Peer Dependency**: `nodemailer` + +## ๐Ÿ“ฑ SMS Senders + +### Twilio + +```typescript +import { TwilioSmsSender } from "@ciscode/notification-kit/infra"; + +const smsSender = new TwilioSmsSender({ + accountSid: "your-account-sid", + authToken: "your-auth-token", + fromNumber: "+1234567890", +}); +``` + +**Peer Dependency**: `twilio` + +### AWS SNS + +```typescript +import { AwsSnsSender } from "@ciscode/notification-kit/infra"; + +const smsSender = new AwsSnsSender({ + region: "us-east-1", + accessKeyId: "your-access-key", + secretAccessKey: "your-secret-key", + senderName: "MyApp", // Optional +}); +``` + +**Peer Dependency**: `@aws-sdk/client-sns` + +### Vonage (Nexmo) + +```typescript +import { VonageSmsSender } from "@ciscode/notification-kit/infra"; + +const smsSender = new VonageSmsSender({ + apiKey: "your-api-key", + apiSecret: "your-api-secret", + from: "MyApp", +}); +``` + +**Peer Dependency**: `@vonage/server-sdk` + +## ๐Ÿ”” Push Notification Senders + +### Firebase Cloud Messaging + +```typescript +import { FirebasePushSender } from "@ciscode/notification-kit/infra"; + +const pushSender = new FirebasePushSender({ + projectId: "your-project-id", + privateKey: "your-private-key", + clientEmail: "your-client-email", +}); +``` + +**Peer Dependency**: `firebase-admin` + +### OneSignal + +```typescript +import { OneSignalPushSender } from "@ciscode/notification-kit/infra"; + +const pushSender = new OneSignalPushSender({ + appId: "your-app-id", + restApiKey: "your-rest-api-key", +}); +``` + +**No additional dependencies** (uses fetch API) + +### AWS SNS (Push) + +```typescript +import { AwsSnsPushSender } from "@ciscode/notification-kit/infra"; + +const pushSender = new AwsSnsPushSender({ + region: "us-east-1", + accessKeyId: "your-access-key", + secretAccessKey: "your-secret-key", + platformApplicationArn: "arn:aws:sns:...", +}); +``` + +**Peer Dependency**: `@aws-sdk/client-sns` + +## ๐Ÿ’พ Repositories + +### MongoDB with Mongoose + +```typescript +import mongoose from "mongoose"; +import { MongooseNotificationRepository } from "@ciscode/notification-kit/infra"; + +const connection = await mongoose.createConnection("mongodb://localhost:27017/mydb"); + +const repository = new MongooseNotificationRepository( + connection, + "notifications", // collection name (optional) +); +``` + +**Peer Dependency**: `mongoose` + +### In-Memory (Testing) + +```typescript +import { InMemoryNotificationRepository } from "@ciscode/notification-kit/infra"; + +const repository = new InMemoryNotificationRepository(); + +// For testing - clear all data +repository.clear(); + +// For testing - get all notifications +const all = repository.getAll(); +``` + +**No dependencies** + +## ๐Ÿ› ๏ธ Utility Providers + +### ID Generator + +```typescript +import { UuidGenerator, ObjectIdGenerator, NanoIdGenerator } from "@ciscode/notification-kit/infra"; + +// UUID v4 +const uuidGen = new UuidGenerator(); +uuidGen.generate(); // "a1b2c3d4-..." + +// MongoDB ObjectId format +const objectIdGen = new ObjectIdGenerator(); +objectIdGen.generate(); // "507f1f77bcf86cd799439011" + +// NanoID (requires nanoid package) +const nanoIdGen = new NanoIdGenerator(); +nanoIdGen.generate(); // "V1StGXR8_Z5jdHi6B-myT" +``` + +### DateTime Provider + +```typescript +import { DateTimeProvider } from "@ciscode/notification-kit/infra"; + +const dateTime = new DateTimeProvider(); + +dateTime.now(); // "2024-01-15T10:30:00.000Z" +dateTime.isPast("2024-01-01T00:00:00.000Z"); // true +dateTime.isFuture("2025-01-01T00:00:00.000Z"); // true +``` + +### Template Engine + +#### Handlebars + +```typescript +import { HandlebarsTemplateEngine } from "@ciscode/notification-kit/infra"; + +const templateEngine = new HandlebarsTemplateEngine({ + templates: { + welcome: { + title: "Welcome {{name}}!", + body: "Hello {{name}}, thanks for joining!", + html: "

Welcome {{name}}!

", + }, + }, +}); + +const result = await templateEngine.render("welcome", { name: "John" }); +// { title: 'Welcome John!', body: 'Hello John, thanks for joining!', html: '

Welcome John!

' } +``` + +**Peer Dependency**: `handlebars` + +#### Simple Template Engine + +```typescript +import { SimpleTemplateEngine } from "@ciscode/notification-kit/infra"; + +const templateEngine = new SimpleTemplateEngine({ + welcome: { + title: "Welcome {{name}}!", + body: "Hello {{name}}, thanks for joining!", + }, +}); + +const result = await templateEngine.render("welcome", { name: "John" }); +// Uses simple {{variable}} replacement +``` + +**No dependencies** + +### Event Emitter + +#### In-Memory Event Emitter + +```typescript +import { InMemoryEventEmitter } from "@ciscode/notification-kit/infra"; + +const eventEmitter = new InMemoryEventEmitter(); + +// Listen to specific events +eventEmitter.on("notification.sent", (event) => { + console.log("Notification sent:", event.notification.id); +}); + +// Listen to all events +eventEmitter.on("*", (event) => { + console.log("Event:", event.type); +}); +``` + +#### Console Event Emitter + +```typescript +import { ConsoleEventEmitter } from "@ciscode/notification-kit/infra"; + +const eventEmitter = new ConsoleEventEmitter(); +// Logs all events to console +``` + +## ๐Ÿ“ฆ Installation + +Install only the peer dependencies you need: + +### Email (Nodemailer) + +```bash +npm install nodemailer +npm install -D @types/nodemailer +``` + +### SMS + +```bash +# Twilio +npm install twilio + +# AWS SNS +npm install @aws-sdk/client-sns + +# Vonage +npm install @vonage/server-sdk +``` + +### Push Notifications + +```bash +# Firebase +npm install firebase-admin + +# AWS SNS (same as SMS) +npm install @aws-sdk/client-sns +``` + +### Repository + +```bash +# Mongoose +npm install mongoose +``` + +### Template Engine + +```bash +# Handlebars +npm install handlebars +npm install -D @types/handlebars +``` + +### ID Generator + +```bash +# NanoID (optional) +npm install nanoid +``` + +## ๐ŸŽฏ Usage with NestJS Module + +These implementations will be used when configuring the NotificationKit module: + +```typescript +import { Module } from "@nestjs/common"; +import { NotificationKitModule } from "@ciscode/notification-kit"; +import { + NodemailerSender, + TwilioSmsSender, + FirebasePushSender, + MongooseNotificationRepository, + UuidGenerator, + DateTimeProvider, + InMemoryEventEmitter, +} from "@ciscode/notification-kit/infra"; + +@Module({ + imports: [ + NotificationKitModule.register({ + senders: [ + new NodemailerSender({ + /* config */ + }), + new TwilioSmsSender({ + /* config */ + }), + new FirebasePushSender({ + /* config */ + }), + ], + repository: new MongooseNotificationRepository(/* mongoose connection */), + idGenerator: new UuidGenerator(), + dateTimeProvider: new DateTimeProvider(), + eventEmitter: new InMemoryEventEmitter(), + }), + ], +}) +export class AppModule {} +``` + +## ๐Ÿ”’ Architecture Notes + +- All implementations use **lazy loading** for peer dependencies +- External packages are imported dynamically to avoid build-time dependencies +- TypeScript errors for missing packages are suppressed with `@ts-expect-error` +- Only install the peer dependencies you actually use diff --git a/src/infra/index.ts b/src/infra/index.ts new file mode 100644 index 0000000..a00d201 --- /dev/null +++ b/src/infra/index.ts @@ -0,0 +1,21 @@ +/** + * Infrastructure Layer + * + * This layer contains concrete implementations of the core interfaces. + * It includes: + * - Notification senders (email, SMS, push) + * - Repositories (MongoDB, in-memory) + * - Utility providers (ID generator, datetime, templates, events) + * + * These implementations are internal and not exported by default. + * They can be used when configuring the NestJS module. + */ + +// Senders +export * from "./senders"; + +// Repositories +export * from "./repositories"; + +// Providers +export * from "./providers"; diff --git a/src/infra/providers/datetime.provider.ts b/src/infra/providers/datetime.provider.ts new file mode 100644 index 0000000..f0ca7fb --- /dev/null +++ b/src/infra/providers/datetime.provider.ts @@ -0,0 +1,28 @@ +import type { IDateTimeProvider } from "../../core"; + +/** + * DateTime provider implementation using native Date + */ +export class DateTimeProvider implements IDateTimeProvider { + now(): string { + return new Date().toISOString(); + } + + isPast(_datetime: string): boolean { + try { + const date = new Date(_datetime); + return date.getTime() < Date.now(); + } catch { + return false; + } + } + + isFuture(_datetime: string): boolean { + try { + const date = new Date(_datetime); + return date.getTime() > Date.now(); + } catch { + return false; + } + } +} diff --git a/src/infra/providers/event-emitter.provider.ts b/src/infra/providers/event-emitter.provider.ts new file mode 100644 index 0000000..c70a908 --- /dev/null +++ b/src/infra/providers/event-emitter.provider.ts @@ -0,0 +1,62 @@ +import type { INotificationEventEmitter, NotificationEvent } from "../../core"; + +export type NotificationEventHandler = (event: NotificationEvent) => void | Promise; + +/** + * Simple in-memory event emitter implementation + */ +export class InMemoryEventEmitter implements INotificationEventEmitter { + private handlers: Map = new Map(); + + async emit(_event: NotificationEvent): Promise { + const handlers = this.handlers.get(_event.type) || []; + const allHandlers = this.handlers.get("*") || []; + + const allPromises = [...handlers, ...allHandlers].map((handler) => { + try { + return Promise.resolve(handler(_event)); + } catch (error) { + console.error(`Error in event handler for ${_event.type}:`, error); + return Promise.resolve(); + } + }); + + await Promise.all(allPromises); + } + + /** + * Register an event handler + */ + on(eventType: NotificationEvent["type"] | "*", handler: NotificationEventHandler): void { + const handlers = this.handlers.get(eventType) || []; + handlers.push(handler); + this.handlers.set(eventType, handlers); + } + + /** + * Unregister an event handler + */ + off(eventType: NotificationEvent["type"] | "*", handler: NotificationEventHandler): void { + const handlers = this.handlers.get(eventType) || []; + const index = handlers.indexOf(handler); + if (index > -1) { + handlers.splice(index, 1); + } + } + + /** + * Clear all handlers + */ + clear(): void { + this.handlers.clear(); + } +} + +/** + * Event emitter that logs events to console + */ +export class ConsoleEventEmitter implements INotificationEventEmitter { + async emit(_event: NotificationEvent): Promise { + console.log(`[NotificationEvent] ${_event.type}`, _event); + } +} diff --git a/src/infra/providers/id-generator.provider.ts b/src/infra/providers/id-generator.provider.ts new file mode 100644 index 0000000..f666614 --- /dev/null +++ b/src/infra/providers/id-generator.provider.ts @@ -0,0 +1,77 @@ +import type { IIdGenerator } from "../../core"; + +/** + * ID generator using UUID v4 + */ +export class UuidGenerator implements IIdGenerator { + generate(): string { + // Simple UUID v4 implementation + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } +} + +/** + * ID generator using MongoDB ObjectId format + */ +export class ObjectIdGenerator implements IIdGenerator { + private counter = Math.floor(Math.random() * 0xffffff); + + generate(): string { + // Generate MongoDB ObjectId-like string (24 hex characters) + const timestamp = Math.floor(Date.now() / 1000) + .toString(16) + .padStart(8, "0"); + const machineId = Math.floor(Math.random() * 0xffffff) + .toString(16) + .padStart(6, "0"); + const processId = Math.floor(Math.random() * 0xffff) + .toString(16) + .padStart(4, "0"); + this.counter = (this.counter + 1) % 0xffffff; + const counter = this.counter.toString(16).padStart(6, "0"); + + return timestamp + machineId + processId + counter; + } +} + +/** + * ID generator using NanoID (requires nanoid package) + * Note: Returns synchronous string, loads nanoid on first use + */ +export class NanoIdGenerator implements IIdGenerator { + private nanoid: (() => string) | null = null; + private initialized = false; + + generate(): string { + if (!this.initialized) { + // For first call, use UUID fallback and initialize in background + this.initialize(); + return new UuidGenerator().generate(); + } + + if (!this.nanoid) { + return new UuidGenerator().generate(); + } + + return this.nanoid(); + } + + private async initialize(): Promise { + if (this.initialized) return; + + try { + // @ts-expect-error - nanoid is an optional peer dependency + const { nanoid } = await import("nanoid"); + this.nanoid = nanoid; + } catch { + // Fallback to UUID if nanoid is not installed + this.nanoid = () => new UuidGenerator().generate(); + } + + this.initialized = true; + } +} diff --git a/src/infra/providers/index.ts b/src/infra/providers/index.ts new file mode 100644 index 0000000..c1c4975 --- /dev/null +++ b/src/infra/providers/index.ts @@ -0,0 +1,5 @@ +// Utility providers +export * from "./id-generator.provider"; +export * from "./datetime.provider"; +export * from "./template.provider"; +export * from "./event-emitter.provider"; diff --git a/src/infra/providers/template.provider.ts b/src/infra/providers/template.provider.ts new file mode 100644 index 0000000..1c50fab --- /dev/null +++ b/src/infra/providers/template.provider.ts @@ -0,0 +1,124 @@ +import type { ITemplateEngine, TemplateResult } from "../../core"; + +export interface HandlebarsTemplateConfig { + templates: Record; +} + +/** + * Template engine implementation using Handlebars + */ +export class HandlebarsTemplateEngine implements ITemplateEngine { + private handlebars: any = null; + private compiledTemplates: Map = new Map(); + + constructor(private readonly config: HandlebarsTemplateConfig) {} + + /** + * Initialize Handlebars lazily + */ + private async getHandlebars(): Promise { + if (this.handlebars) { + return this.handlebars; + } + + const Handlebars = await import("handlebars"); + this.handlebars = Handlebars.default || Handlebars; + + return this.handlebars; + } + + async render(_templateId: string, _variables: Record): Promise { + const template = this.config.templates[_templateId]; + + if (!template) { + throw new Error(`Template ${_templateId} not found`); + } + + const handlebars = await this.getHandlebars(); + + // Compile and cache templates + if (!this.compiledTemplates.has(_templateId)) { + const compiled = { + title: handlebars.compile(template.title), + body: handlebars.compile(template.body), + html: template.html ? handlebars.compile(template.html) : undefined, + }; + this.compiledTemplates.set(_templateId, compiled); + } + + const compiled = this.compiledTemplates.get(_templateId)!; + + return { + title: compiled.title(_variables), + body: compiled.body(_variables), + html: compiled.html ? compiled.html(_variables) : undefined, + }; + } + + async hasTemplate(_templateId: string): Promise { + return !!this.config.templates[_templateId]; + } + + async validateVariables( + _templateId: string, + _variables: Record, + ): Promise { + try { + await this.render(_templateId, _variables); + return true; + } catch { + return false; + } + } +} + +/** + * Simple template engine using string replacement + */ +export class SimpleTemplateEngine implements ITemplateEngine { + constructor( + private readonly templates: Record, + ) {} + + async render(_templateId: string, _variables: Record): Promise { + const template = this.templates[_templateId]; + + if (!template) { + throw new Error(`Template ${_templateId} not found`); + } + + const result: TemplateResult = { + title: this.replaceVariables(template.title, _variables), + body: this.replaceVariables(template.body, _variables), + }; + + if (template.html) { + result.html = this.replaceVariables(template.html, _variables); + } + + return result; + } + + async hasTemplate(_templateId: string): Promise { + return !!this.templates[_templateId]; + } + + async validateVariables( + _templateId: string, + _variables: Record, + ): Promise { + try { + await this.render(_templateId, _variables); + return true; + } catch { + return false; + } + } + + private replaceVariables(template: string, variables: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => { + const value = variables[key]; + return value !== undefined ? String(value) : ""; + }); + } +} diff --git a/src/infra/repositories/in-memory/in-memory.repository.ts b/src/infra/repositories/in-memory/in-memory.repository.ts new file mode 100644 index 0000000..c98edcf --- /dev/null +++ b/src/infra/repositories/in-memory/in-memory.repository.ts @@ -0,0 +1,178 @@ +import type { + INotificationRepository, + Notification, + NotificationQueryCriteria, +} from "../../../core"; + +/** + * In-memory repository implementation for testing/simple cases + */ +export class InMemoryNotificationRepository implements INotificationRepository { + private notifications: Map = new Map(); + private idCounter = 1; + + async create( + _notification: Omit, + ): Promise { + const now = new Date().toISOString(); + const id = `notif_${this.idCounter++}`; + + const notification: Notification = { + id, + ..._notification, + createdAt: now, + updatedAt: now, + }; + + this.notifications.set(id, notification); + + return notification; + } + + async findById(_id: string): Promise { + return this.notifications.get(_id) || null; + } + + async find(_criteria: NotificationQueryCriteria): Promise { + let results = Array.from(this.notifications.values()); + + // Apply filters + if (_criteria.recipientId) { + results = results.filter((n) => n.recipient.id === _criteria.recipientId); + } + + if (_criteria.channel) { + results = results.filter((n) => n.channel === _criteria.channel); + } + + if (_criteria.status) { + results = results.filter((n) => n.status === _criteria.status); + } + + if (_criteria.priority) { + results = results.filter((n) => n.priority === _criteria.priority); + } + + if (_criteria.fromDate) { + results = results.filter((n) => n.createdAt >= _criteria.fromDate!); + } + + if (_criteria.toDate) { + results = results.filter((n) => n.createdAt <= _criteria.toDate!); + } + + // Sort by createdAt descending + results.sort((a, b) => (b.createdAt > a.createdAt ? 1 : -1)); + + // Apply pagination + const offset = _criteria.offset || 0; + const limit = _criteria.limit || 10; + + return results.slice(offset, offset + limit); + } + + async update(_id: string, _updates: Partial): Promise { + const notification = this.notifications.get(_id); + + if (!notification) { + throw new Error(`Notification with id ${_id} not found`); + } + + const updated: Notification = { + ...notification, + ..._updates, + id: notification.id, // Preserve ID + createdAt: notification.createdAt, // Preserve createdAt + updatedAt: new Date().toISOString(), + }; + + this.notifications.set(_id, updated); + + return updated; + } + + async delete(_id: string): Promise { + return this.notifications.delete(_id); + } + + async count(_criteria: NotificationQueryCriteria): Promise { + let results = Array.from(this.notifications.values()); + + // Apply filters + if (_criteria.recipientId) { + results = results.filter((n) => n.recipient.id === _criteria.recipientId); + } + + if (_criteria.channel) { + results = results.filter((n) => n.channel === _criteria.channel); + } + + if (_criteria.status) { + results = results.filter((n) => n.status === _criteria.status); + } + + if (_criteria.priority) { + results = results.filter((n) => n.priority === _criteria.priority); + } + + if (_criteria.fromDate) { + results = results.filter((n) => n.createdAt >= _criteria.fromDate!); + } + + if (_criteria.toDate) { + results = results.filter((n) => n.createdAt <= _criteria.toDate!); + } + + return results.length; + } + + async findReadyToSend(_limit: number): Promise { + const now = new Date().toISOString(); + let results = Array.from(this.notifications.values()); + + // Find notifications ready to send + results = results.filter((n) => { + // Pending notifications that are scheduled and ready + if (n.status === "pending" && n.scheduledFor && n.scheduledFor <= now) { + return true; + } + + // Queued notifications (ready to send immediately) + if (n.status === "queued") { + return true; + } + + // Failed notifications that haven't exceeded retry count + if (n.status === "failed" && n.retryCount < n.maxRetries) { + return true; + } + + return false; + }); + + // Sort by priority (high to low) then by createdAt (oldest first) + const priorityOrder: Record = { urgent: 4, high: 3, normal: 2, low: 1 }; + results.sort((a, b) => { + const priorityDiff = (priorityOrder[b.priority] || 0) - (priorityOrder[a.priority] || 0); + if (priorityDiff !== 0) return priorityDiff; + return a.createdAt > b.createdAt ? 1 : -1; + }); + + return results.slice(0, _limit); + } + + /** + * Clear all notifications (for testing) + */ + clear(): void { + this.notifications.clear(); + this.idCounter = 1; + } + + /** + * Get all notifications (for testing) + */ + getAll(): Notification[] { + return Array.from(this.notifications.values()); + } +} diff --git a/src/infra/repositories/index.ts b/src/infra/repositories/index.ts new file mode 100644 index 0000000..fab52b3 --- /dev/null +++ b/src/infra/repositories/index.ts @@ -0,0 +1,6 @@ +// MongoDB/Mongoose repository +export * from "./mongoose/notification.schema"; +export * from "./mongoose/mongoose.repository"; + +// In-memory repository +export * from "./in-memory/in-memory.repository"; diff --git a/src/infra/repositories/mongoose/mongoose.repository.ts b/src/infra/repositories/mongoose/mongoose.repository.ts new file mode 100644 index 0000000..06d36fa --- /dev/null +++ b/src/infra/repositories/mongoose/mongoose.repository.ts @@ -0,0 +1,261 @@ +import type { Model, Connection } from "mongoose"; + +import type { + INotificationRepository, + Notification, + NotificationQueryCriteria, +} from "../../../core"; + +import type { CreateNotificationInput, NotificationDocument } from "./notification.schema"; +import { notificationSchemaDefinition } from "./notification.schema"; + +/** + * MongoDB repository implementation using Mongoose + */ +export class MongooseNotificationRepository implements INotificationRepository { + private model: Model | null = null; + + constructor( + private readonly connection: Connection, + private readonly collectionName: string = "notifications", + ) {} + + /** + * Get or create the Mongoose model + */ + private getModel(): Model { + if (this.model) { + return this.model; + } + + const mongoose = (this.connection as any).base; + const schema = new mongoose.Schema(notificationSchemaDefinition, { + collection: this.collectionName, + timestamps: false, // We handle timestamps manually + }); + + // Add indexes + schema.index({ "recipient.id": 1, createdAt: -1 }); + schema.index({ status: 1, scheduledFor: 1 }); + schema.index({ channel: 1, createdAt: -1 }); + schema.index({ createdAt: -1 }); + + this.model = this.connection.model( + "Notification", + schema, + this.collectionName, + ); + + return this.model; + } + + async create( + _notification: Omit, + ): Promise { + const Model = this.getModel(); + + const now = new Date().toISOString(); + const doc = await Model.create({ + ..._notification, + createdAt: now, + updatedAt: now, + } as CreateNotificationInput); + + return this.documentToNotification(doc); + } + + async findById(_id: string): Promise { + const Model = this.getModel(); + const doc = await Model.findById(_id).exec(); + + if (!doc) { + return null; + } + + return this.documentToNotification(doc); + } + + async find(_criteria: NotificationQueryCriteria): Promise { + const Model = this.getModel(); + + const filter: any = {}; + + if (_criteria.recipientId) { + filter["recipient.id"] = _criteria.recipientId; + } + + if (_criteria.channel) { + filter.channel = _criteria.channel; + } + + if (_criteria.status) { + filter.status = _criteria.status; + } + + if (_criteria.priority) { + filter.priority = _criteria.priority; + } + + if (_criteria.fromDate || _criteria.toDate) { + filter.createdAt = {}; + if (_criteria.fromDate) { + filter.createdAt.$gte = _criteria.fromDate; + } + if (_criteria.toDate) { + filter.createdAt.$lte = _criteria.toDate; + } + } + + const query = Model.find(filter).sort({ createdAt: -1 }); + + if (_criteria.limit) { + query.limit(_criteria.limit); + } + + if (_criteria.offset) { + query.skip(_criteria.offset); + } + + const docs = await query.exec(); + + return docs.map((doc: NotificationDocument) => this.documentToNotification(doc)); + } + + async update(_id: string, _updates: Partial): Promise { + const Model = this.getModel(); + + const updateData: any = { ..._updates }; + updateData.updatedAt = new Date().toISOString(); + + // Remove id and timestamps from updates if present + delete updateData.id; + delete updateData.createdAt; + + const doc = await Model.findByIdAndUpdate(_id, updateData, { new: true }).exec(); + + if (!doc) { + throw new Error(`Notification with id ${_id} not found`); + } + + return this.documentToNotification(doc); + } + + async delete(_id: string): Promise { + const Model = this.getModel(); + const result = await Model.findByIdAndDelete(_id).exec(); + return !!result; + } + + async count(_criteria: NotificationQueryCriteria): Promise { + const Model = this.getModel(); + + const filter: any = {}; + + if (_criteria.recipientId) { + filter["recipient.id"] = _criteria.recipientId; + } + + if (_criteria.channel) { + filter.channel = _criteria.channel; + } + + if (_criteria.status) { + filter.status = _criteria.status; + } + + if (_criteria.priority) { + filter.priority = _criteria.priority; + } + + if (_criteria.fromDate || _criteria.toDate) { + filter.createdAt = {}; + if (_criteria.fromDate) { + filter.createdAt.$gte = _criteria.fromDate; + } + if (_criteria.toDate) { + filter.createdAt.$lte = _criteria.toDate; + } + } + + return Model.countDocuments(filter).exec(); + } + + async findReadyToSend(_limit: number): Promise { + const Model = this.getModel(); + + const now = new Date().toISOString(); + + const docs = await Model.find({ + $or: [ + // Pending notifications that are scheduled and ready + { + status: "pending", + scheduledFor: { $lte: now }, + }, + // Queued notifications (ready to send immediately) + { + status: "queued", + }, + // Failed notifications that haven't exceeded retry count + { + status: "failed", + $expr: { $lt: ["$retryCount", "$maxRetries"] }, + }, + ], + }) + .sort({ priority: -1, createdAt: 1 }) // High priority first, then oldest + .limit(_limit) + .exec(); + + return docs.map((doc: NotificationDocument) => this.documentToNotification(doc)); + } + + /** + * Convert Mongoose document to Notification entity + */ + private documentToNotification(doc: NotificationDocument): Notification { + return { + id: doc._id.toString(), + channel: doc.channel, + status: doc.status, + priority: doc.priority, + recipient: { + id: doc.recipient.id, + email: doc.recipient.email, + phone: doc.recipient.phone, + deviceToken: doc.recipient.deviceToken, + metadata: doc.recipient.metadata ? this.mapToRecord(doc.recipient.metadata) : undefined, + }, + content: { + title: doc.content.title, + body: doc.content.body, + html: doc.content.html, + data: doc.content.data ? this.mapToRecord(doc.content.data) : undefined, + templateId: doc.content.templateId, + templateVars: doc.content.templateVars + ? this.mapToRecord(doc.content.templateVars) + : undefined, + }, + scheduledFor: doc.scheduledFor, + sentAt: doc.sentAt, + deliveredAt: doc.deliveredAt, + error: doc.error, + retryCount: doc.retryCount, + maxRetries: doc.maxRetries, + metadata: doc.metadata ? this.mapToRecord(doc.metadata) : undefined, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; + } + + /** + * Convert Mongoose Map to plain object + */ + private mapToRecord(map: any): Record { + if (map instanceof Map) { + return Object.fromEntries(map); + } + // If it's already an object, return as-is + return map as Record; + } +} diff --git a/src/infra/repositories/mongoose/notification.schema.ts b/src/infra/repositories/mongoose/notification.schema.ts new file mode 100644 index 0000000..0efa32f --- /dev/null +++ b/src/infra/repositories/mongoose/notification.schema.ts @@ -0,0 +1,88 @@ +import type { + Notification, + NotificationChannel, + NotificationContent, + NotificationPriority, + NotificationRecipient, + NotificationStatus, +} from "../../../core"; + +// Helper to get Schema type at runtime (for Mongoose schema definitions) +const getSchemaTypes = () => { + try { + // @ts-expect-error - mongoose is an optional peer dependency + const mongoose = require("mongoose"); + return mongoose.Schema.Types; + } catch { + return { Mixed: {} }; + } +}; + +const SchemaTypes = getSchemaTypes(); + +/** + * Mongoose schema definition for Notification + */ +export interface NotificationDocument extends Omit { + _id: string; +} + +export const notificationSchemaDefinition = { + channel: { + type: String, + required: true, + enum: ["email", "sms", "push", "in_app", "webhook"], + }, + status: { + type: String, + required: true, + enum: ["pending", "queued", "sending", "sent", "delivered", "failed", "cancelled"], + }, + priority: { + type: String, + required: true, + enum: ["low", "normal", "high", "urgent"], + }, + recipient: { + id: { type: String, required: true }, + email: { type: String }, + phone: { type: String }, + deviceToken: { type: String }, + metadata: { type: Map, of: SchemaTypes.Mixed }, + }, + content: { + title: { type: String, required: true }, + body: { type: String, required: true }, + html: { type: String }, + data: { type: Map, of: SchemaTypes.Mixed }, + templateId: { type: String }, + templateVars: { type: Map, of: SchemaTypes.Mixed }, + }, + scheduledFor: { type: String }, + sentAt: { type: String }, + deliveredAt: { type: String }, + error: { type: String }, + retryCount: { type: Number, required: true, default: 0 }, + maxRetries: { type: Number, required: true, default: 3 }, + metadata: { type: Map, of: SchemaTypes.Mixed }, + createdAt: { type: String, required: true }, + updatedAt: { type: String, required: true }, +}; + +/** + * Type helper for creating a new notification + */ +export type CreateNotificationInput = { + channel: NotificationChannel; + status: NotificationStatus; + priority: NotificationPriority; + recipient: NotificationRecipient; + content: NotificationContent; + scheduledFor?: string | undefined; + sentAt?: string | undefined; + deliveredAt?: string | undefined; + error?: string | undefined; + retryCount: number; + maxRetries: number; + metadata?: Record | undefined; +}; diff --git a/src/infra/senders/email/nodemailer.sender.ts b/src/infra/senders/email/nodemailer.sender.ts new file mode 100644 index 0000000..6855b63 --- /dev/null +++ b/src/infra/senders/email/nodemailer.sender.ts @@ -0,0 +1,119 @@ +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +export interface NodemailerConfig { + host: string; + port: number; + secure?: boolean | undefined; + auth?: + | { + user: string; + pass: string; + } + | undefined; + from: string; + fromName?: string | undefined; +} + +/** + * Email sender implementation using Nodemailer + * Supports any SMTP provider (Gmail, SendGrid, AWS SES, etc.) + */ +export class NodemailerSender implements INotificationSender { + readonly channel: NotificationChannel = "email" as NotificationChannel; + private transporter: any = null; + + constructor(private readonly config: NodemailerConfig) {} + + /** + * Initialize the nodemailer transporter lazily + */ + private async getTransporter(): Promise { + if (this.transporter) { + return this.transporter; + } + + // Dynamic import to avoid requiring nodemailer at build time + // @ts-expect-error - nodemailer is an optional peer dependency + const nodemailer = await import("nodemailer"); + + this.transporter = nodemailer.createTransport({ + host: this.config.host, + port: this.config.port, + secure: this.config.secure ?? false, + auth: this.config.auth, + }); + + return this.transporter; + } + + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + if (!_recipient.email) { + return { + success: false, + notificationId: "", + error: "Recipient email is required", + }; + } + + const transporter = await this.getTransporter(); + + const mailOptions = { + 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, + }; + + const info = await transporter.sendMail(mailOptions); + + return { + success: true, + notificationId: _recipient.id, + providerMessageId: info.messageId, + metadata: { + accepted: info.accepted, + rejected: info.rejected, + response: info.response, + }, + }; + } catch (error) { + return { + success: false, + notificationId: _recipient.id, + error: error instanceof Error ? error.message : "Failed to send email", + }; + } + } + + async isReady(): Promise { + try { + const transporter = await this.getTransporter(); + await transporter.verify(); + return true; + } catch { + return false; + } + } + + validateRecipient(_recipient: NotificationRecipient): boolean { + return !!_recipient.email && this.isValidEmail(_recipient.email); + } + + 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 new file mode 100644 index 0000000..77ceae4 --- /dev/null +++ b/src/infra/senders/index.ts @@ -0,0 +1,12 @@ +// Email senders +export * from "./email/nodemailer.sender"; + +// SMS senders +export * from "./sms/twilio.sender"; +export * from "./sms/aws-sns.sender"; +export * from "./sms/vonage.sender"; + +// Push notification senders +export * from "./push/firebase.sender"; +export * from "./push/onesignal.sender"; +export * from "./push/aws-sns-push.sender"; diff --git a/src/infra/senders/push/aws-sns-push.sender.ts b/src/infra/senders/push/aws-sns-push.sender.ts new file mode 100644 index 0000000..55ed0a5 --- /dev/null +++ b/src/infra/senders/push/aws-sns-push.sender.ts @@ -0,0 +1,128 @@ +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +export interface AwsSnsPushConfig { + region: string; + accessKeyId: string; + secretAccessKey: string; + platformApplicationArn: string; +} + +/** + * Push notification sender implementation using AWS SNS + */ +export class AwsSnsPushSender implements INotificationSender { + readonly channel: NotificationChannel = "push" as NotificationChannel; + private sns: any = null; + + constructor(private readonly config: AwsSnsPushConfig) {} + + /** + * Initialize AWS SNS client lazily + */ + private async getClient(): Promise { + if (this.sns) { + return this.sns; + } + + // Dynamic import to avoid requiring @aws-sdk at build time + // @ts-expect-error - @aws-sdk/client-sns is an optional peer dependency + const { SNSClient, PublishCommand } = await import("@aws-sdk/client-sns"); + + this.sns = { + client: new SNSClient({ + region: this.config.region, + credentials: { + accessKeyId: this.config.accessKeyId, + secretAccessKey: this.config.secretAccessKey, + }, + }), + PublishCommand, + }; + + return this.sns; + } + + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + if (!_recipient.deviceToken) { + return { + success: false, + notificationId: _recipient.id, + error: "Recipient device token (endpoint ARN) is required", + }; + } + + const { client, PublishCommand } = await this.getClient(); + + // For AWS SNS push, the message format depends on the platform + const message = JSON.stringify({ + default: _content.body, + GCM: JSON.stringify({ + notification: { + title: _content.title, + body: _content.body, + }, + data: _content.data, + }), + APNS: JSON.stringify({ + aps: { + alert: { + title: _content.title, + body: _content.body, + }, + }, + data: _content.data, + }), + }); + + const params = { + Message: message, + MessageStructure: "json", + TargetArn: _recipient.deviceToken, // This should be the endpoint ARN + }; + + const command = new PublishCommand(params); + const response = await client.send(command); + + return { + success: true, + notificationId: _recipient.id, + providerMessageId: response.MessageId, + }; + } catch (error) { + return { + success: false, + notificationId: _recipient.id, + error: + error instanceof Error ? error.message : "Failed to send push notification via AWS SNS", + }; + } + } + + async isReady(): Promise { + try { + const { client } = await this.getClient(); + return !!client; + } catch { + return false; + } + } + + validateRecipient(_recipient: NotificationRecipient): boolean { + // For AWS SNS, deviceToken should be an endpoint ARN + return ( + !!_recipient.deviceToken && + _recipient.deviceToken.startsWith("arn:aws:sns:") && + _recipient.deviceToken.includes(":endpoint/") + ); + } +} diff --git a/src/infra/senders/push/firebase.sender.ts b/src/infra/senders/push/firebase.sender.ts new file mode 100644 index 0000000..18b5a08 --- /dev/null +++ b/src/infra/senders/push/firebase.sender.ts @@ -0,0 +1,104 @@ +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +export interface FirebaseConfig { + projectId: string; + privateKey: string; + clientEmail: string; +} + +/** + * Push notification sender implementation using Firebase Cloud Messaging (FCM) + */ +export class FirebasePushSender implements INotificationSender { + readonly channel: NotificationChannel = "push" as NotificationChannel; + private app: any = null; + private messaging: any = null; + + constructor(private readonly config: FirebaseConfig) {} + + /** + * Initialize Firebase app lazily + */ + private async getMessaging(): Promise { + if (this.messaging) { + return this.messaging; + } + + // Dynamic import to avoid requiring firebase-admin at build time + // @ts-expect-error - firebase-admin is an optional peer dependency + const admin = await import("firebase-admin"); + + if (!this.app) { + this.app = admin.initializeApp({ + credential: admin.credential.cert({ + projectId: this.config.projectId, + privateKey: this.config.privateKey.replace(/\\n/g, "\n"), + clientEmail: this.config.clientEmail, + }), + }); + } + + this.messaging = admin.messaging(this.app); + + return this.messaging; + } + + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + if (!_recipient.deviceToken) { + return { + success: false, + notificationId: _recipient.id, + error: "Recipient device token is required", + }; + } + + const messaging = await this.getMessaging(); + + const message = { + token: _recipient.deviceToken, + notification: { + title: _content.title, + body: _content.body, + }, + data: _content.data as Record | undefined, + }; + + const messageId = await messaging.send(message); + + return { + success: true, + notificationId: _recipient.id, + providerMessageId: messageId, + }; + } catch (error) { + return { + success: false, + notificationId: _recipient.id, + error: error instanceof Error ? error.message : "Failed to send push notification via FCM", + }; + } + } + + async isReady(): Promise { + try { + const messaging = await this.getMessaging(); + return !!messaging; + } catch { + return false; + } + } + + validateRecipient(_recipient: NotificationRecipient): boolean { + return !!_recipient.deviceToken && _recipient.deviceToken.length > 0; + } +} diff --git a/src/infra/senders/push/onesignal.sender.ts b/src/infra/senders/push/onesignal.sender.ts new file mode 100644 index 0000000..85a0845 --- /dev/null +++ b/src/infra/senders/push/onesignal.sender.ts @@ -0,0 +1,97 @@ +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +export interface OneSignalConfig { + appId: string; + restApiKey: string; +} + +/** + * Push notification sender implementation using OneSignal + */ +export class OneSignalPushSender implements INotificationSender { + readonly channel: NotificationChannel = "push" as NotificationChannel; + private readonly baseUrl = "https://onesignal.com/api/v1"; + + constructor(private readonly config: OneSignalConfig) {} + + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + if (!_recipient.deviceToken) { + return { + success: false, + notificationId: _recipient.id, + error: "Recipient device token is required", + }; + } + + const response = await fetch(`${this.baseUrl}/notifications`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${this.config.restApiKey}`, + }, + body: JSON.stringify({ + app_id: this.config.appId, + include_player_ids: [_recipient.deviceToken], + headings: { en: _content.title }, + contents: { en: _content.body }, + data: _content.data, + }), + }); + + const result = await response.json(); + + if (response.ok && result.id) { + return { + success: true, + notificationId: _recipient.id, + providerMessageId: result.id, + metadata: { + recipients: result.recipients, + }, + }; + } else { + return { + success: false, + notificationId: _recipient.id, + error: result.errors?.[0] || "Failed to send push notification via OneSignal", + }; + } + } catch (error) { + return { + success: false, + notificationId: _recipient.id, + error: + error instanceof Error ? error.message : "Failed to send push notification via OneSignal", + }; + } + } + + async isReady(): Promise { + try { + // Verify API key by fetching app info + const response = await fetch(`${this.baseUrl}/apps/${this.config.appId}`, { + headers: { + Authorization: `Basic ${this.config.restApiKey}`, + }, + }); + + return response.ok; + } catch { + return false; + } + } + + validateRecipient(_recipient: NotificationRecipient): boolean { + return !!_recipient.deviceToken && _recipient.deviceToken.length > 0; + } +} diff --git a/src/infra/senders/sms/aws-sns.sender.ts b/src/infra/senders/sms/aws-sns.sender.ts new file mode 100644 index 0000000..12685a9 --- /dev/null +++ b/src/infra/senders/sms/aws-sns.sender.ts @@ -0,0 +1,119 @@ +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +export interface AwsSnsConfig { + region: string; + accessKeyId: string; + secretAccessKey: string; + senderName?: string; +} + +/** + * SMS sender implementation using AWS SNS + */ +export class AwsSnsSender implements INotificationSender { + readonly channel: NotificationChannel = "sms" as NotificationChannel; + private sns: any = null; + + constructor(private readonly config: AwsSnsConfig) {} + + /** + * Initialize AWS SNS client lazily + */ + private async getClient(): Promise { + if (this.sns) { + return this.sns; + } + + // Dynamic import to avoid requiring @aws-sdk at build time + // @ts-expect-error - @aws-sdk/client-sns is an optional peer dependency + const { SNSClient, PublishCommand } = await import("@aws-sdk/client-sns"); + + this.sns = { + client: new SNSClient({ + region: this.config.region, + credentials: { + accessKeyId: this.config.accessKeyId, + secretAccessKey: this.config.secretAccessKey, + }, + }), + PublishCommand, + }; + + return this.sns; + } + + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + if (!_recipient.phone) { + return { + success: false, + notificationId: _recipient.id, + error: "Recipient phone number is required", + }; + } + + const { client, PublishCommand } = await this.getClient(); + + const params: any = { + Message: _content.body, + PhoneNumber: _recipient.phone, + }; + + if (this.config.senderName) { + params.MessageAttributes = { + "AWS.SNS.SMS.SenderID": { + DataType: "String", + StringValue: this.config.senderName, + }, + }; + } + + const command = new PublishCommand(params); + const response = await client.send(command); + + return { + success: true, + notificationId: _recipient.id, + providerMessageId: response.MessageId, + metadata: { + sequenceNumber: response.SequenceNumber, + }, + }; + } catch (error) { + return { + success: false, + notificationId: _recipient.id, + error: error instanceof Error ? error.message : "Failed to send SMS via AWS SNS", + }; + } + } + + async isReady(): Promise { + try { + const { client } = await this.getClient(); + // Check if client is initialized + return !!client; + } catch { + return false; + } + } + + validateRecipient(_recipient: NotificationRecipient): boolean { + return !!_recipient.phone && this.isValidPhoneNumber(_recipient.phone); + } + + private isValidPhoneNumber(phone: string): boolean { + // Basic E.164 format validation + const phoneRegex = /^\+[1-9]\d{1,14}$/; + return phoneRegex.test(phone); + } +} diff --git a/src/infra/senders/sms/twilio.sender.ts b/src/infra/senders/sms/twilio.sender.ts new file mode 100644 index 0000000..744ef6b --- /dev/null +++ b/src/infra/senders/sms/twilio.sender.ts @@ -0,0 +1,100 @@ +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +export interface TwilioConfig { + accountSid: string; + authToken: string; + fromNumber: string; +} + +/** + * SMS sender implementation using Twilio + */ +export class TwilioSmsSender implements INotificationSender { + readonly channel: NotificationChannel = "sms" as NotificationChannel; + private client: any = null; + + constructor(private readonly config: TwilioConfig) {} + + /** + * Initialize Twilio client lazily + */ + private async getClient(): Promise { + if (this.client) { + return this.client; + } + + // Dynamic import to avoid requiring twilio at build time + // @ts-expect-error - twilio is an optional peer dependency + const twilio = await import("twilio"); + this.client = twilio.default(this.config.accountSid, this.config.authToken); + + return this.client; + } + + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + if (!_recipient.phone) { + return { + success: false, + notificationId: _recipient.id, + error: "Recipient phone number is required", + }; + } + + const client = await this.getClient(); + + const message = await client.messages.create({ + body: _content.body, + from: this.config.fromNumber, + to: _recipient.phone, + }); + + return { + success: true, + notificationId: _recipient.id, + providerMessageId: message.sid, + metadata: { + status: message.status, + price: message.price, + priceUnit: message.priceUnit, + }, + }; + } catch (error) { + return { + success: false, + notificationId: _recipient.id, + error: error instanceof Error ? error.message : "Failed to send SMS via Twilio", + }; + } + } + + async isReady(): Promise { + try { + const client = await this.getClient(); + // Try to fetch account info to verify credentials + await client.api.accounts(this.config.accountSid).fetch(); + return true; + } catch { + return false; + } + } + + validateRecipient(_recipient: NotificationRecipient): boolean { + return !!_recipient.phone && this.isValidPhoneNumber(_recipient.phone); + } + + private isValidPhoneNumber(phone: string): boolean { + // Basic E.164 format validation + const phoneRegex = /^\+[1-9]\d{1,14}$/; + return phoneRegex.test(phone); + } +} diff --git a/src/infra/senders/sms/vonage.sender.ts b/src/infra/senders/sms/vonage.sender.ts new file mode 100644 index 0000000..831b297 --- /dev/null +++ b/src/infra/senders/sms/vonage.sender.ts @@ -0,0 +1,113 @@ +import type { + INotificationSender, + NotificationChannel, + NotificationContent, + NotificationRecipient, + NotificationResult, +} from "../../../core"; + +export interface VonageConfig { + apiKey: string; + apiSecret: string; + from: string; +} + +/** + * SMS sender implementation using Vonage (formerly Nexmo) + */ +export class VonageSmsSender implements INotificationSender { + readonly channel: NotificationChannel = "sms" as NotificationChannel; + private client: any = null; + + constructor(private readonly config: VonageConfig) {} + + /** + * Initialize Vonage client lazily + */ + private async getClient(): Promise { + if (this.client) { + return this.client; + } + + // Dynamic import to avoid requiring @vonage/server-sdk at build time + // @ts-expect-error - @vonage/server-sdk is an optional peer dependency + const { Vonage } = await import("@vonage/server-sdk"); + + this.client = new Vonage({ + apiKey: this.config.apiKey, + apiSecret: this.config.apiSecret, + }); + + return this.client; + } + + async send( + _recipient: NotificationRecipient, + _content: NotificationContent, + ): Promise { + try { + if (!_recipient.phone) { + return { + success: false, + notificationId: _recipient.id, + error: "Recipient phone number is required", + }; + } + + const client = await this.getClient(); + + const response = await client.sms.send({ + to: _recipient.phone, + from: this.config.from, + text: _content.body, + }); + + const message = response.messages[0]; + + if (message.status === "0") { + return { + success: true, + notificationId: _recipient.id, + providerMessageId: message["message-id"], + metadata: { + networkCode: message["network-code"], + price: message["message-price"], + remainingBalance: message["remaining-balance"], + }, + }; + } else { + return { + success: false, + notificationId: _recipient.id, + error: message["error-text"] || "Failed to send SMS via Vonage", + }; + } + } catch (error) { + return { + success: false, + notificationId: _recipient.id, + error: error instanceof Error ? error.message : "Failed to send SMS via Vonage", + }; + } + } + + async isReady(): Promise { + try { + const client = await this.getClient(); + // Check if client is initialized + return !!client; + } catch { + return false; + } + } + + validateRecipient(_recipient: NotificationRecipient): boolean { + return !!_recipient.phone && this.isValidPhoneNumber(_recipient.phone); + } + + private isValidPhoneNumber(phone: string): boolean { + // Basic E.164 format validation + const phoneRegex = /^\+[1-9]\d{1,14}$/; + return phoneRegex.test(phone); + } +} diff --git a/src/nest/constants.ts b/src/nest/constants.ts new file mode 100644 index 0000000..947bdf7 --- /dev/null +++ b/src/nest/constants.ts @@ -0,0 +1,11 @@ +/** + * Injection tokens for NotificationKit providers + */ +export const NOTIFICATION_KIT_OPTIONS = Symbol("NOTIFICATION_KIT_OPTIONS"); +export const NOTIFICATION_SERVICE = Symbol("NOTIFICATION_SERVICE"); +export const NOTIFICATION_REPOSITORY = Symbol("NOTIFICATION_REPOSITORY"); +export const NOTIFICATION_SENDERS = Symbol("NOTIFICATION_SENDERS"); +export const NOTIFICATION_ID_GENERATOR = Symbol("NOTIFICATION_ID_GENERATOR"); +export const NOTIFICATION_DATETIME_PROVIDER = Symbol("NOTIFICATION_DATETIME_PROVIDER"); +export const NOTIFICATION_TEMPLATE_ENGINE = Symbol("NOTIFICATION_TEMPLATE_ENGINE"); +export const NOTIFICATION_EVENT_EMITTER = Symbol("NOTIFICATION_EVENT_EMITTER"); diff --git a/src/nest/controllers/notification.controller.ts b/src/nest/controllers/notification.controller.ts new file mode 100644 index 0000000..b0ae5b0 --- /dev/null +++ b/src/nest/controllers/notification.controller.ts @@ -0,0 +1,221 @@ +import { + Body, + Controller, + Delete, + Get, + Inject, + Param, + Patch, + Post, + Query, + HttpCode, + HttpStatus, + NotFoundException, + BadRequestException, +} from "@nestjs/common"; + +import type { + BulkSendNotificationDto, + CreateNotificationDto, + QueryNotificationsDto, + SendNotificationDto, +} from "../../core/dtos"; +import { NotificationNotFoundError, ValidationError } from "../../core/errors"; +import type { NotificationService } from "../../core/notification.service"; +import { NOTIFICATION_KIT_OPTIONS, NOTIFICATION_SERVICE } from "../constants"; +import type { NotificationKitModuleOptions } from "../interfaces"; + +/** + * REST API controller for notification operations + */ +@Controller() +export class NotificationController { + private readonly prefix: string; + + constructor( + @Inject(NOTIFICATION_SERVICE) + private readonly notificationService: NotificationService, + @Inject(NOTIFICATION_KIT_OPTIONS) + private readonly options: NotificationKitModuleOptions, + ) { + this.prefix = options.apiPrefix || "notifications"; + } + + /** + * Send a notification + * POST /notifications/send + */ + @Post("send") + @HttpCode(HttpStatus.CREATED) + async send(@Body() dto: SendNotificationDto) { + try { + return await this.notificationService.send(dto); + } catch (error) { + if (error instanceof ValidationError) { + throw new BadRequestException(error.message); + } + throw error; + } + } + + /** + * Send bulk notifications + * POST /notifications/bulk-send + */ + @Post("bulk-send") + @HttpCode(HttpStatus.ACCEPTED) + async bulkSend(@Body() dto: BulkSendNotificationDto) { + try { + // Convert bulk DTO to individual send requests + const results = await Promise.allSettled( + dto.recipients.map((recipient) => + this.notificationService.send({ + ...dto, + recipient, + }), + ), + ); + + return { + total: results.length, + succeeded: results.filter((r: PromiseSettledResult) => r.status === "fulfilled") + .length, + failed: results.filter((r: PromiseSettledResult) => r.status === "rejected").length, + results: results.map((r: PromiseSettledResult, index: number) => ({ + index, + status: r.status, + notification: r.status === "fulfilled" ? r.value : undefined, + error: r.status === "rejected" ? String(r.reason) : undefined, + })), + }; + } catch (error) { + if (error instanceof ValidationError) { + throw new BadRequestException(error.message); + } + throw error; + } + } + + /** + * Create a notification without sending + * POST /notifications + */ + @Post() + @HttpCode(HttpStatus.CREATED) + async create(@Body() dto: CreateNotificationDto) { + try { + return await this.notificationService.create(dto); + } catch (error) { + if (error instanceof ValidationError) { + throw new BadRequestException(error.message); + } + throw error; + } + } + + /** + * Get notification by ID + * GET /notifications/:id + */ + @Get(":id") + async getById(@Param("id") id: string) { + try { + return await this.notificationService.getById(id); + } catch (error) { + if (error instanceof NotificationNotFoundError) { + throw new NotFoundException(error.message); + } + throw error; + } + } + + /** + * Query notifications + * GET /notifications + */ + @Get() + async query(@Query() queryDto: QueryNotificationsDto) { + try { + // Build query criteria + const criteria: any = { + limit: queryDto.limit, + offset: queryDto.offset, + }; + + if (queryDto.recipientId) criteria.recipientId = queryDto.recipientId; + if (queryDto.channel) criteria.channel = queryDto.channel; + if (queryDto.status) criteria.status = queryDto.status; + if (queryDto.priority) criteria.priority = queryDto.priority; + if (queryDto.fromDate) criteria.fromDate = queryDto.fromDate; + if (queryDto.toDate) criteria.toDate = queryDto.toDate; + + const [notifications, total] = await Promise.all([ + this.notificationService.query(criteria), + this.notificationService.count(criteria), + ]); + + return { + data: notifications, + total, + limit: queryDto.limit, + offset: queryDto.offset, + }; + } catch (error) { + if (error instanceof ValidationError) { + throw new BadRequestException(error.message); + } + throw error; + } + } + + /** + * Retry sending a notification + * POST /notifications/:id/retry + */ + @Post(":id/retry") + @HttpCode(HttpStatus.OK) + async retry(@Param("id") id: string) { + try { + return await this.notificationService.retry(id); + } catch (error) { + if (error instanceof NotificationNotFoundError) { + throw new NotFoundException(error.message); + } + throw error; + } + } + + /** + * Cancel a notification + * DELETE /notifications/:id/cancel + */ + @Delete(":id/cancel") + @HttpCode(HttpStatus.OK) + async cancel(@Param("id") id: string) { + try { + return await this.notificationService.cancel(id); + } catch (error) { + if (error instanceof NotificationNotFoundError) { + throw new NotFoundException(error.message); + } + throw error; + } + } + + /** + * Mark notification as delivered (webhook callback) + * PATCH /notifications/:id/delivered + */ + @Patch(":id/delivered") + @HttpCode(HttpStatus.OK) + async markAsDelivered(@Param("id") id: string, @Body() body: { metadata?: Record }) { + try { + return await this.notificationService.markAsDelivered(id, body.metadata); + } catch (error) { + if (error instanceof NotificationNotFoundError) { + throw new NotFoundException(error.message); + } + throw error; + } + } +} diff --git a/src/nest/controllers/webhook.controller.ts b/src/nest/controllers/webhook.controller.ts new file mode 100644 index 0000000..547b76d --- /dev/null +++ b/src/nest/controllers/webhook.controller.ts @@ -0,0 +1,144 @@ +import { + Body, + Controller, + Headers, + HttpCode, + HttpStatus, + Inject, + Post, + UnauthorizedException, + BadRequestException, +} from "@nestjs/common"; + +import { NotificationNotFoundError } from "../../core/errors"; +import type { NotificationService } from "../../core/notification.service"; +import { NOTIFICATION_KIT_OPTIONS, NOTIFICATION_SERVICE } from "../constants"; +import type { NotificationKitModuleOptions } from "../interfaces"; + +/** + * Webhook payload from notification providers + */ +interface WebhookPayload { + notificationId: string; + status?: "delivered" | "failed" | "bounced" | "complained"; + deliveredAt?: string; + provider?: string; + metadata?: Record; +} + +/** + * Webhook controller for receiving delivery status callbacks from providers + */ +@Controller() +export class WebhookController { + private readonly path: string; + private readonly secret: string | undefined; + + constructor( + @Inject(NOTIFICATION_SERVICE) + private readonly notificationService: NotificationService, + @Inject(NOTIFICATION_KIT_OPTIONS) + private readonly options: NotificationKitModuleOptions, + ) { + this.path = options.webhookPath || "webhooks/notifications"; + this.secret = options.webhookSecret; + } + + /** + * Handle webhook callbacks from notification providers + * POST /webhooks/notifications + */ + @Post() + @HttpCode(HttpStatus.OK) + async handleWebhook( + @Headers("x-webhook-secret") webhookSecret: string | undefined, + @Headers("x-webhook-signature") webhookSignature: string | undefined, + @Body() payload: WebhookPayload | WebhookPayload[], + ) { + // Verify webhook secret if configured + if (this.secret) { + if (!webhookSecret && !webhookSignature) { + throw new UnauthorizedException("Missing webhook authentication"); + } + + if (webhookSecret && webhookSecret !== this.secret) { + throw new UnauthorizedException("Invalid webhook secret"); + } + + // TODO: Implement signature verification for production use + // This would verify HMAC signatures from providers like AWS SNS, Twilio, etc. + } + + try { + // Handle single or batch webhooks + const payloads = Array.isArray(payload) ? payload : [payload]; + const results = []; + + for (const item of payloads) { + try { + // Validate payload + if (!item.notificationId) { + throw new BadRequestException("Missing notificationId in webhook payload"); + } + + // Process based on status + if (item.status === "delivered") { + const notification = await this.notificationService.markAsDelivered( + item.notificationId, + item.metadata, + ); + results.push({ success: true, notificationId: item.notificationId, notification }); + } else if (item.status === "failed" || item.status === "bounced") { + // Mark as failed and potentially retry + const notification = await this.notificationService.getById(item.notificationId); + if (notification.retryCount < (notification.maxRetries || 3)) { + await this.notificationService.retry(item.notificationId); + results.push({ + success: true, + notificationId: item.notificationId, + action: "retried", + }); + } else { + results.push({ + success: true, + notificationId: item.notificationId, + action: "max_retries_reached", + }); + } + } else { + // Unknown status, just log it + results.push({ + success: true, + notificationId: item.notificationId, + action: "logged", + status: item.status, + }); + } + } catch (error) { + if (error instanceof NotificationNotFoundError) { + results.push({ + success: false, + notificationId: item.notificationId, + error: "notification_not_found", + }); + } else { + results.push({ + success: false, + notificationId: item.notificationId, + error: String(error), + }); + } + } + } + + return { + received: payloads.length, + processed: results.filter((r) => r.success).length, + failed: results.filter((r) => !r.success).length, + results, + }; + } catch (error) { + throw new BadRequestException(`Failed to process webhook: ${String(error)}`); + } + } +} diff --git a/src/nest/decorators.ts b/src/nest/decorators.ts new file mode 100644 index 0000000..c79962a --- /dev/null +++ b/src/nest/decorators.ts @@ -0,0 +1,52 @@ +import { Inject } from "@nestjs/common"; + +import { + NOTIFICATION_DATETIME_PROVIDER, + NOTIFICATION_EVENT_EMITTER, + NOTIFICATION_ID_GENERATOR, + NOTIFICATION_KIT_OPTIONS, + NOTIFICATION_REPOSITORY, + NOTIFICATION_SENDERS, + NOTIFICATION_SERVICE, + NOTIFICATION_TEMPLATE_ENGINE, +} from "./constants"; + +/** + * Inject NotificationService + */ +export const InjectNotificationService = () => Inject(NOTIFICATION_SERVICE); + +/** + * Inject NotificationKit module options + */ +export const InjectNotificationKitOptions = () => Inject(NOTIFICATION_KIT_OPTIONS); + +/** + * Inject notification repository + */ +export const InjectNotificationRepository = () => Inject(NOTIFICATION_REPOSITORY); + +/** + * Inject notification senders + */ +export const InjectNotificationSenders = () => Inject(NOTIFICATION_SENDERS); + +/** + * Inject ID generator + */ +export const InjectIdGenerator = () => Inject(NOTIFICATION_ID_GENERATOR); + +/** + * Inject DateTime provider + */ +export const InjectDateTimeProvider = () => Inject(NOTIFICATION_DATETIME_PROVIDER); + +/** + * Inject template engine + */ +export const InjectTemplateEngine = () => Inject(NOTIFICATION_TEMPLATE_ENGINE); + +/** + * Inject event emitter + */ +export const InjectEventEmitter = () => Inject(NOTIFICATION_EVENT_EMITTER); diff --git a/src/nest/index.ts b/src/nest/index.ts index b999044..ffd8c09 100644 --- a/src/nest/index.ts +++ b/src/nest/index.ts @@ -1 +1,18 @@ +// Module export * from "./module"; + +// Interfaces +export * from "./interfaces"; + +// Constants +export * from "./constants"; + +// Decorators +export * from "./decorators"; + +// Controllers +export * from "./controllers/notification.controller"; +export * from "./controllers/webhook.controller"; + +// Providers +export * from "./providers"; diff --git a/src/nest/interfaces.ts b/src/nest/interfaces.ts new file mode 100644 index 0000000..c1a0a2e --- /dev/null +++ b/src/nest/interfaces.ts @@ -0,0 +1,112 @@ +import type { ModuleMetadata, Type } from "@nestjs/common"; + +import type { + IDateTimeProvider, + IIdGenerator, + INotificationEventEmitter, + INotificationRepository, + INotificationSender, + ITemplateEngine, +} from "../core"; + +/** + * Options for configuring NotificationKit module + */ +export interface NotificationKitModuleOptions { + /** + * Array of notification senders for different channels + */ + senders: INotificationSender[]; + + /** + * Repository implementation for persisting notifications + */ + repository: INotificationRepository; + + /** + * ID generator for creating notification IDs + * @default UuidGenerator + */ + idGenerator?: IIdGenerator; + + /** + * DateTime provider for timestamps + * @default DateTimeProvider + */ + dateTimeProvider?: IDateTimeProvider; + + /** + * Optional template engine for rendering notification templates + */ + templateEngine?: ITemplateEngine; + + /** + * Optional event emitter for notification events + */ + eventEmitter?: INotificationEventEmitter; + + /** + * Enable REST API endpoints + * @default true + */ + enableRestApi?: boolean; + + /** + * REST API route prefix + * @default 'notifications' + */ + apiPrefix?: string; + + /** + * Enable webhook endpoint for delivery status callbacks + * @default true + */ + enableWebhooks?: boolean; + + /** + * Webhook route path + * @default 'notifications/webhooks' + */ + webhookPath?: string; + + /** + * Webhook secret for validating incoming requests + */ + webhookSecret?: string; +} + +/** + * Factory for creating NotificationKit options asynchronously + */ +export interface NotificationKitOptionsFactory { + createNotificationKitOptions(): + | Promise + | NotificationKitModuleOptions; +} + +/** + * Options for registerAsync + */ +export interface NotificationKitModuleAsyncOptions extends Pick { + /** + * Use existing options factory + */ + useExisting?: Type; + + /** + * Use class as options factory + */ + useClass?: Type; + + /** + * Use factory function + */ + useFactory?: ( + ...args: any[] + ) => Promise | NotificationKitModuleOptions; + + /** + * Dependencies to inject into factory function + */ + inject?: any[]; +} diff --git a/src/nest/module.ts b/src/nest/module.ts index 61f623b..1775f11 100644 --- a/src/nest/module.ts +++ b/src/nest/module.ts @@ -1,17 +1,147 @@ -import { Module } from "@nestjs/common"; -import type { DynamicModule } from "@nestjs/common"; +import { Module, type DynamicModule, type Provider, type Type } from "@nestjs/common"; -export type NotificationKitModuleOptions = Record; +import { NOTIFICATION_KIT_OPTIONS } from "./constants"; +import { NotificationController } from "./controllers/notification.controller"; +import { WebhookController } from "./controllers/webhook.controller"; +import type { + NotificationKitModuleAsyncOptions, + NotificationKitModuleOptions, + NotificationKitOptionsFactory, +} from "./interfaces"; +import { createNotificationKitProviders } from "./providers"; @Module({}) export class NotificationKitModule { - static register(_options: NotificationKitModuleOptions = {}): DynamicModule { - void _options; + /** + * Register module synchronously with direct configuration + */ + static register(options: NotificationKitModuleOptions): DynamicModule { + const providers = this.createProviders(options); + const controllers = this.createControllers(options); + const exports = providers.map((p) => (typeof p === "object" && "provide" in p ? p.provide : p)); return { + global: true, module: NotificationKitModule, - providers: [], - exports: [], + controllers, + providers, + exports, }; } + + /** + * Register module asynchronously with factory pattern + */ + static registerAsync(options: NotificationKitModuleAsyncOptions): DynamicModule { + const asyncOptionsProvider = this.createAsyncOptionsProvider(options); + 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 + const providersFactory: Provider = { + provide: "NOTIFICATION_PROVIDERS", + useFactory: (moduleOptions: NotificationKitModuleOptions) => { + return createNotificationKitProviders(moduleOptions); + }, + inject: [NOTIFICATION_KIT_OPTIONS], + }; + + const allProviders = [asyncOptionsProvider, ...asyncProviders, providersFactory]; + const exports = allProviders.map((p) => + typeof p === "object" && "provide" in p ? p.provide : p, + ); + + return { + global: true, + module: NotificationKitModule, + imports: options.imports || [], + controllers: [], // Controllers disabled in async mode for simplicity + providers: allProviders, + exports, + }; + } + + /** + * Create providers including options and service providers + */ + private static createProviders(options: NotificationKitModuleOptions): Provider[] { + return [ + { + provide: NOTIFICATION_KIT_OPTIONS, + useValue: options, + }, + ...createNotificationKitProviders(options), + ]; + } + + /** + * Create controllers based on options + */ + private static createControllers(options: NotificationKitModuleOptions): Type[] { + const controllers: Type[] = []; + + // Add REST API controller if enabled (default: true) + if (options.enableRestApi !== false) { + controllers.push(NotificationController); + } + + // Add webhook controller if enabled (default: true) + if (options.enableWebhooks !== false) { + controllers.push(WebhookController); + } + + return controllers; + } + + /** + * Create async providers for registerAsync + */ + private static createAsyncProviders(options: NotificationKitModuleAsyncOptions): Provider[] { + if (options.useClass) { + return [ + { + provide: options.useClass, + useClass: options.useClass, + }, + ]; + } + + return []; + } + + /** + * Create async options provider + */ + private static createAsyncOptionsProvider(options: NotificationKitModuleAsyncOptions): Provider { + if (options.useFactory) { + return { + provide: NOTIFICATION_KIT_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [], + }; + } + + if (options.useExisting) { + return { + provide: NOTIFICATION_KIT_OPTIONS, + useFactory: async (optionsFactory: NotificationKitOptionsFactory) => { + return optionsFactory.createNotificationKitOptions(); + }, + inject: [options.useExisting], + }; + } + + if (options.useClass) { + return { + provide: NOTIFICATION_KIT_OPTIONS, + useFactory: async (optionsFactory: NotificationKitOptionsFactory) => { + return optionsFactory.createNotificationKitOptions(); + }, + inject: [options.useClass], + }; + } + + throw new Error("Invalid NotificationKitModuleAsyncOptions"); + } } diff --git a/src/nest/providers.ts b/src/nest/providers.ts new file mode 100644 index 0000000..5053a2b --- /dev/null +++ b/src/nest/providers.ts @@ -0,0 +1,117 @@ +import type { Provider } from "@nestjs/common"; + +import { NotificationService } from "../core/notification.service"; +import { DateTimeProvider as _DateTimeProvider } from "../infra/providers/datetime.provider"; +import { UuidGenerator as _UuidGenerator } from "../infra/providers/id-generator.provider"; + +import { + NOTIFICATION_DATETIME_PROVIDER, + NOTIFICATION_EVENT_EMITTER, + NOTIFICATION_ID_GENERATOR, + NOTIFICATION_REPOSITORY, + NOTIFICATION_SENDERS, + NOTIFICATION_SERVICE, + NOTIFICATION_TEMPLATE_ENGINE, +} from "./constants"; +import type { NotificationKitModuleOptions } from "./interfaces"; + +/** + * Create providers for NotificationKit module + */ +export function createNotificationKitProviders(options: NotificationKitModuleOptions): Provider[] { + const providers: Provider[] = []; + + // Senders provider + providers.push({ + provide: NOTIFICATION_SENDERS, + useValue: options.senders, + }); + + // Repository provider + providers.push({ + provide: NOTIFICATION_REPOSITORY, + useValue: options.repository, + }); + + // ID Generator provider + if (options.idGenerator) { + providers.push({ + provide: NOTIFICATION_ID_GENERATOR, + useValue: options.idGenerator, + }); + } else { + // Default to UuidGenerator + providers.push({ + provide: NOTIFICATION_ID_GENERATOR, + useFactory: async () => { + const { UuidGenerator } = await import("../infra/providers/id-generator.provider"); + return new UuidGenerator(); + }, + }); + } + + // DateTime Provider + if (options.dateTimeProvider) { + providers.push({ + provide: NOTIFICATION_DATETIME_PROVIDER, + useValue: options.dateTimeProvider, + }); + } else { + // Default to DateTimeProvider + providers.push({ + provide: NOTIFICATION_DATETIME_PROVIDER, + useFactory: async () => { + const { DateTimeProvider } = await import("../infra/providers/datetime.provider"); + return new DateTimeProvider(); + }, + }); + } + + // Template Engine provider (optional) + if (options.templateEngine) { + providers.push({ + provide: NOTIFICATION_TEMPLATE_ENGINE, + useValue: options.templateEngine, + }); + } + + // Event Emitter provider (optional) + if (options.eventEmitter) { + providers.push({ + provide: NOTIFICATION_EVENT_EMITTER, + useValue: options.eventEmitter, + }); + } + + // NotificationService provider + providers.push({ + provide: NOTIFICATION_SERVICE, + useFactory: ( + repository: any, + idGenerator: any, + dateTimeProvider: any, + senders: any[], + templateEngine?: any, + eventEmitter?: any, + ) => { + return new NotificationService( + repository, + idGenerator, + dateTimeProvider, + senders, + templateEngine, + eventEmitter, + ); + }, + inject: [ + NOTIFICATION_REPOSITORY, + NOTIFICATION_ID_GENERATOR, + NOTIFICATION_DATETIME_PROVIDER, + NOTIFICATION_SENDERS, + { token: NOTIFICATION_TEMPLATE_ENGINE, optional: true }, + { token: NOTIFICATION_EVENT_EMITTER, optional: true }, + ], + }); + + return providers; +} diff --git a/tsconfig.json b/tsconfig.json index 63ab110..f6dbfd9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "module": "ESNext", "moduleResolution": "Bundler", "declaration": true, @@ -14,8 +14,9 @@ "esModuleInterop": true, "resolveJsonModule": true, "skipLibCheck": true, - "types": ["jest"], - "baseUrl": "." + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "types": ["jest"] }, "include": ["src/**/*.ts", "test/**/*.ts"], "exclude": ["dist", "node_modules"] diff --git a/tsup.config.ts b/tsup.config.ts index 798d116..b60fd92 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -10,4 +10,16 @@ export default defineConfig({ target: "es2022", outDir: "dist", tsconfig: "tsconfig.build.json", + external: [ + "@nestjs/common", + "nodemailer", + "twilio", + "@aws-sdk/client-sns", + "@vonage/server-sdk", + "firebase-admin", + "mongoose", + "handlebars", + "nanoid", + "zod", + ], });