diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..14512f5 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,903 @@ +# Copilot Instructions - @ciscode/notification-kit + +> **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. + +--- + +## ๐ŸŽฏ Package Overview + +**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 Package Provides: + +- 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 +- Copilot-friendly development guidelines + +--- + +## ๐Ÿ—๏ธ Module Architecture + +**NotificationKit uses CSR (Controller-Service-Repository) + Ports & Adapters for maximum reusability and provider interchangeability.** + +> **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 โ€” all exports go through here + โ”‚ + โ”œโ”€โ”€ 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) + โ”‚ + โ”œโ”€โ”€ 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 + โ”‚ + โ””โ”€โ”€ 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, 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", +} + +enum NotificationStatus { + PENDING = "pending", + QUEUED = "queued", + SENDING = "sending", + SENT = "sent", + DELIVERED = "delivered", + FAILED = "failed", + CANCELLED = "cancelled", +} +``` + +### Path Aliases (`tsconfig.json`) + +```typescript +"@/*" โ†’ "src/*" +"@core/*" โ†’ "src/core/*" +"@infra/*" โ†’ "src/infra/*" +"@nest/*" โ†’ "src/nest/*" +``` + +Use aliases for cleaner imports: + +```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"; +``` + +--- + +## ๐Ÿ“ฆ Public API โ€” `src/index.ts` + +```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 +``` + +**What consuming apps should use:** + +```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"; +``` + +**โŒ NEVER export:** + +- Internal provider wiring (`createNotificationKitProviders` internals) +- Raw SDK instances (Nodemailer transporter, Twilio client, Firebase app) +- Mongoose schema definitions (infrastructure details) + +--- + +## โš™๏ธ Module Registration + +### `register()` โ€” sync + +```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" }, +}); +``` + +### `registerAsync()` โ€” with ConfigService + +```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), + }), +}); +``` + +### `registerAsync()` โ€” with `useClass` / `useExisting` + +```typescript +// 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 +``` + +### `INotificationSender` Port + +All channel senders implement this port. To add a new channel or provider, implement this interface in `infra/senders//`: + +```typescript +// core/ports/notification-sender.port.ts +interface INotificationSender { + readonly channel: NotificationChannel; + send(notification: Notification): Promise; + isConfigured(): boolean; +} +``` + +### `INotificationRepository` Port + +All persistence adapters implement this. Apps never depend on Mongoose schemas directly: + +```typescript +// 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:** + +- โœ… `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:** + +- โœ… `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:** + +- โœ… Send notification โ†’ delivery โ†’ status update (per channel) +- โœ… Retry flow (failure โ†’ retry โ†’ success) +- โœ… Scheduled notification lifecycle + +**Test file location:** same directory as source (`*.spec.ts`) + +``` +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:** + +```javascript +coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, +} +``` + +--- + +## ๐Ÿ“š Documentation - Complete + +### JSDoc/TSDoc - ALWAYS for: + +````typescript +/** + * 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 result = await notificationService.send({ + * channel: NotificationChannel.EMAIL, + * recipient: { id: 'user-1', email: 'user@example.com' }, + * content: { title: 'Welcome', body: 'Hello!' }, + * priority: NotificationPriority.NORMAL, + * }); + * ``` + */ +async send(dto: SendNotificationDto): Promise +```` + +**Required for:** + +- 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: + +```typescript +@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 send(@Body() dto: SendNotificationDto): Promise {} +``` + +--- + +## ๐Ÿš€ Module Development Principles + +### 1. Exportability + +**Export ONLY public API:** + +```typescript +// src/index.ts +export * from "./core"; // Types, DTOs, ports, errors, NotificationService +export * from "./infra"; // Senders, repositories, providers +export * from "./nest"; // NotificationKitModule, interfaces +``` + +**โŒ NEVER export:** + +- Raw SDK clients (Nodemailer transporter, Twilio client instances) +- Internal `createNotificationKitProviders()` wiring details +- Mongoose schema definitions + +### 2. Configuration + +**All three async patterns supported:** + +```typescript +@Module({}) +export class NotificationKitModule { + static register(options: NotificationKitModuleOptions): DynamicModule { + /* ... */ + } + 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 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 + +--- + +## ๐Ÿ”„ Workflow & Task Management + +### Task-Driven Development + +**1. Branch Creation:** + +```bash +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/NOTIF-123-add-vonage-sms-sender.md +``` + +**3. On Release:** +Move to archive: + +``` +docs/tasks/archive/by-release/v1.0.0/NOTIF-123-add-vonage-sms-sender.md +``` + +### Development Workflow + +**Simple changes**: Read context โ†’ Implement โ†’ Update docs โ†’ **Create changeset** + +**Complex changes**: Read context โ†’ Discuss approach โ†’ Implement โ†’ Update docs โ†’ **Create changeset** + +**When blocked**: + +- **DO**: Ask immediately +- **DON'T**: Generate incorrect output + +--- + +## ๐Ÿ“ฆ Versioning & Breaking Changes + +### Semantic Versioning (Strict) + +**MAJOR** (x.0.0) โ€” Breaking changes: + +- 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: + +- 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: + +- Provider-specific delivery fix +- Retry backoff correction +- Template rendering edge case +- Documentation updates + +### Changesets Workflow + +**ALWAYS create a changeset for user-facing changes:** + +```bash +npx changeset +``` + +**When to create a changeset:** + +- โœ… New features, bug fixes, breaking changes, performance improvements +- โŒ Internal refactoring (no user impact) +- โŒ Documentation updates only +- โŒ Test improvements only + +**Before completing any task:** + +- [ ] Code implemented +- [ ] Tests passing +- [ ] Documentation updated +- [ ] **Changeset created** โ† CRITICAL +- [ ] PR ready + +**Changeset format:** + +```markdown +--- +"@ciscode/notification-kit": minor +--- + +Added Vonage SMS sender adapter as an alternative to Twilio +``` + +### CHANGELOG Required + +Changesets automatically generates CHANGELOG. For manual additions: + +```markdown +## [1.0.0] - 2026-02-26 + +### BREAKING CHANGES + +- `NotificationService.send()` now requires `priority` field in `SendNotificationDto` +- Removed `createDefaultNotificationService()` โ€” use `NotificationKitModule.register()` instead + +### Added + +- Vonage SMS sender adapter +- `sendBatch()` method on `NotificationService` +- In-memory repository for testing and lightweight usage + +### Fixed + +- Firebase push sender now correctly retries on token expiry (401) +``` + +--- + +## ๐Ÿ” Security Best Practices + +**ALWAYS:** + +- โœ… 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 +// โŒ 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 + +**NEVER without approval:** + +- 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 (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 + +--- + +## โœ… Release Checklist + +Before publishing: + +- [ ] All tests passing (100% of test suite) +- [ ] Coverage >= 80% +- [ ] No ESLint warnings (`--max-warnings=0`) +- [ ] TypeScript strict mode passing (`tsc --noEmit`) +- [ ] `npm run build` succeeds โ€” both `.mjs` and `.cjs` outputs in `dist/` +- [ ] All public APIs documented (JSDoc) +- [ ] All new `NotificationKitModuleOptions` fields documented in README +- [ ] Optional peer deps documented (which to install for which channel) +- [ ] Changeset created +- [ ] Breaking changes highlighted in changeset +- [ ] Integration tested via `npm link` in a real consuming NestJS app + +--- + +## ๐Ÿ”„ Development Workflow + +### Working on the Module: + +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 a Consuming App: + +```bash +# In notification-kit +npm run build +npm link + +# In your NestJS app +cd ~/ciscode/backend +npm link @ciscode/notification-kit + +# Develop and test +# Unlink when done +npm unlink @ciscode/notification-kit +``` + +--- + +## ๐ŸŽจ Code Style + +- ESLint `--max-warnings=0` +- Prettier formatting +- TypeScript strict mode +- 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 EmailSender implements INotificationSender { + readonly channel = NotificationChannel.EMAIL; + + constructor( + @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 +} +``` + +--- + +## ๐Ÿ› Error Handling + +**Custom domain errors โ€” ALWAYS in `core/errors/`:** + +```typescript +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(`Notification "${id}" not found`); + this.name = "NotificationNotFoundError"; + } +} +``` + +**Structured logging โ€” safe identifiers only:** + +```typescript +this.logger.error("Notification delivery failed", { + notificationId: notification.id, + channel: notification.channel, + provider: "twilio", + attempt: notification.retryCount, +}); +``` + +**NEVER silent failures:** + +```typescript +// โŒ WRONG +try { + await sender.send(notification); +} catch { + // silent +} + +// โœ… CORRECT +try { + await sender.send(notification); +} catch (error) { + await this.repository.updateStatus(notification.id, NotificationStatus.FAILED, { + error: (error as Error).message, + }); + throw error; +} +``` + +--- + +## ๐Ÿ’ฌ Communication Style + +- Brief and direct +- 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 + +--- + +## ๐Ÿ“‹ Summary + +**Module Principles:** + +1. Reusability over specificity +2. Comprehensive testing (80%+) +3. Complete documentation +4. Strict versioning +5. Breaking changes = MAJOR bump + changeset +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 26, 2026_ +_Version: 1.0.0_ diff --git a/src/infra/README.md b/src/infra/README.md index 4d8a640..db3da1f 100644 --- a/src/infra/README.md +++ b/src/infra/README.md @@ -135,62 +135,37 @@ const pushSender = new AwsSnsPushSender({ ## ๐Ÿ’พ Repositories -> **Note**: Repository implementations are provided by separate database packages. -> Install the appropriate package for your database: - -### MongoDB - -Install the MongoDB package: - -```bash -npm install @ciscode/notification-kit-mongodb -``` +### MongoDB with Mongoose ```typescript -import { MongooseNotificationRepository } from "@ciscode/notification-kit-mongodb"; 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); -``` - -### PostgreSQL - -Install the PostgreSQL package: -```bash -npm install @ciscode/notification-kit-postgres +const repository = new MongooseNotificationRepository( + connection, + "notifications", // collection name (optional) +); ``` -### Custom Repository +**Peer Dependency**: `mongoose` -Implement the `INotificationRepository` interface: +### In-Memory (Testing) ```typescript -import type { INotificationRepository, Notification } from "@ciscode/notification-kit"; +import { InMemoryNotificationRepository } from "@ciscode/notification-kit/infra"; -class MyCustomRepository implements INotificationRepository { - async create(data: Omit): Promise { - // Your implementation - } +const repository = new InMemoryNotificationRepository(); - async findById(id: string): Promise { - // Your implementation - } +// For testing - clear all data +repository.clear(); - // ... implement other methods -} +// For testing - get all notifications +const all = repository.getAll(); ``` -### Schema Reference - -The MongoDB schema is exported as a reference: - -```typescript -import { notificationSchemaDefinition } from "@ciscode/notification-kit/infra"; - -// Use this as a reference for your own schema implementations -``` +**No dependencies** ## ๐Ÿ› ๏ธ Utility Providers diff --git a/src/infra/index.ts b/src/infra/index.ts index bf33edc..be2acd8 100644 --- a/src/infra/index.ts +++ b/src/infra/index.ts @@ -3,12 +3,12 @@ * * This layer contains concrete implementations of the core interfaces. * It includes: - * - Notification senders (email, SMS, push) - * - Repository schemas (reference implementations) + * - Notification senders (email, SMS, push, WhatsApp) + * - Repositories (MongoDB, in-memory) * - Utility providers (ID generator, datetime, templates, events) * - * NOTE: Repository implementations are provided by separate database packages. - * Install the appropriate package: @ciscode/notification-kit-mongodb, etc. + * These implementations are internal and not exported by default. + * They can be used when configuring the NestJS module. */ // Senders 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 index ea7c204..fab52b3 100644 --- a/src/infra/repositories/index.ts +++ b/src/infra/repositories/index.ts @@ -1,14 +1,6 @@ -/** - * Repository schemas and types - * - * NOTE: Concrete repository implementations are provided by separate packages. - * Install the appropriate database package: - * - @ciscode/notification-kit-mongodb - * - @ciscode/notification-kit-postgres - * - etc. - * - * These schemas serve as reference for implementing your own repository. - */ - -// MongoDB/Mongoose schema (reference) +// 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 index ad3365e..f0ecf0f 100644 --- a/src/infra/repositories/mongoose/notification.schema.ts +++ b/src/infra/repositories/mongoose/notification.schema.ts @@ -10,6 +10,7 @@ import type { // Helper to get Schema type at runtime (for Mongoose schema definitions) const getSchemaTypes = () => { try { + // @ts-ignore - mongoose is an optional peer dependency const mongoose = require("mongoose"); return mongoose.Schema.Types; } catch { diff --git a/src/infra/senders/whatsapp/index.ts b/src/infra/senders/whatsapp/index.ts index 83cdbd5..b0e2820 100644 --- a/src/infra/senders/whatsapp/index.ts +++ b/src/infra/senders/whatsapp/index.ts @@ -4,7 +4,9 @@ * This module exports all WhatsApp sender implementations: * - TwilioWhatsAppSender: Real WhatsApp sender using Twilio API * - MockWhatsAppSender: Mock sender for testing without credentials + * - whatsapp.utils: Shared validation utilities */ export * from "./twilio-whatsapp.sender"; export * from "./mock-whatsapp.sender"; +export * from "./whatsapp.utils"; diff --git a/src/infra/senders/whatsapp/mock-whatsapp.sender.ts b/src/infra/senders/whatsapp/mock-whatsapp.sender.ts index a174268..a8f4e39 100644 --- a/src/infra/senders/whatsapp/mock-whatsapp.sender.ts +++ b/src/infra/senders/whatsapp/mock-whatsapp.sender.ts @@ -45,6 +45,8 @@ import type { NotificationResult, } from "../../../core"; +import { isValidPhoneNumber, validateWhatsAppRecipient, WHATSAPP_ERRORS } from "./whatsapp.utils"; + /** * Configuration for Mock WhatsApp sender */ @@ -89,16 +91,16 @@ export class MockWhatsAppSender implements INotificationSender { return { success: false, notificationId: _recipient.id, - error: "Recipient phone number is required for WhatsApp", + error: WHATSAPP_ERRORS.PHONE_REQUIRED, }; } // Validate phone format - if (!this.isValidPhoneNumber(_recipient.phone)) { + if (!isValidPhoneNumber(_recipient.phone)) { return { success: false, notificationId: _recipient.id, - error: `Invalid phone number format. Must be E.164 format (e.g., +1234567890). Got: ${_recipient.phone}`, + error: WHATSAPP_ERRORS.INVALID_PHONE_FORMAT(_recipient.phone), }; } @@ -162,22 +164,6 @@ export class MockWhatsAppSender implements INotificationSender { * @returns boolean - true if phone exists and is valid */ validateRecipient(_recipient: NotificationRecipient): boolean { - return !!_recipient.phone && this.isValidPhoneNumber(_recipient.phone); - } - - /** - * Validate phone number is in E.164 format - * - * E.164 format: +[country code][number] - * Examples: +14155551234, +447911123456, +212612345678 - * - * @param phone - Phone number to validate - * @returns boolean - true if valid E.164 format - * @private - */ - private isValidPhoneNumber(phone: string): boolean { - // E.164 format: + followed by 1-15 digits - const phoneRegex = /^\+[1-9]\d{1,14}$/; - return phoneRegex.test(phone); + return validateWhatsAppRecipient(_recipient); } } diff --git a/src/infra/senders/whatsapp/twilio-whatsapp.sender.ts b/src/infra/senders/whatsapp/twilio-whatsapp.sender.ts index a37e9b1..c3bb91a 100644 --- a/src/infra/senders/whatsapp/twilio-whatsapp.sender.ts +++ b/src/infra/senders/whatsapp/twilio-whatsapp.sender.ts @@ -64,6 +64,8 @@ import type { NotificationResult, } from "../../../core"; +import { isValidPhoneNumber, validateWhatsAppRecipient, WHATSAPP_ERRORS } from "./whatsapp.utils"; + /** * Configuration for Twilio WhatsApp sender * @@ -163,16 +165,16 @@ export class TwilioWhatsAppSender implements INotificationSender { return { success: false, notificationId: _recipient.id, - error: "Recipient phone number is required for WhatsApp", + error: WHATSAPP_ERRORS.PHONE_REQUIRED, }; } // Validate phone number format (E.164) - if (!this.isValidPhoneNumber(_recipient.phone)) { + if (!isValidPhoneNumber(_recipient.phone)) { return { success: false, notificationId: _recipient.id, - error: `Invalid phone number format. Must be E.164 format (e.g., +1234567890). Got: ${_recipient.phone}`, + error: WHATSAPP_ERRORS.INVALID_PHONE_FORMAT(_recipient.phone), }; } @@ -289,35 +291,6 @@ export class TwilioWhatsAppSender implements INotificationSender { * Called by NotificationService before attempting to send. */ validateRecipient(_recipient: NotificationRecipient): boolean { - return !!_recipient.phone && this.isValidPhoneNumber(_recipient.phone); - } - - /** - * Validate phone number is in E.164 format - * - * E.164 format: +[country code][number] - * - Starts with + - * - Followed by 1-3 digit country code - * - Followed by up to 15 total digits - * - * Valid examples: - * - +14155551234 (USA) - * - +447911123456 (UK) - * - +212612345678 (Morocco) - * - +33612345678 (France) - * - * Invalid examples: - * - 4155551234 (missing +) - * - +1-415-555-1234 (contains dashes) - * - +1 (415) 555-1234 (contains spaces and parentheses) - * - * @param phone - Phone number to validate - * @returns boolean - true if valid E.164 format - * @private - */ - private isValidPhoneNumber(phone: string): boolean { - // E.164 format regex: + followed by 1-15 digits, no spaces or special chars - const phoneRegex = /^\+[1-9]\d{1,14}$/; - return phoneRegex.test(phone); + return validateWhatsAppRecipient(_recipient); } } diff --git a/src/infra/senders/whatsapp/whatsapp.utils.ts b/src/infra/senders/whatsapp/whatsapp.utils.ts new file mode 100644 index 0000000..a72d9bc --- /dev/null +++ b/src/infra/senders/whatsapp/whatsapp.utils.ts @@ -0,0 +1,41 @@ +/** + * WhatsApp Utilities + * + * Shared utility functions for WhatsApp senders to avoid code duplication. + */ + +import type { NotificationRecipient } from "../../../core"; + +/** + * Validate phone number is in E.164 format + * + * E.164 format: +[country code][number] + * Examples: +14155551234, +447911123456, +212612345678 + * + * @param phone - Phone number to validate + * @returns boolean - true if valid E.164 format + */ +export function isValidPhoneNumber(phone: string): boolean { + // E.164 format: + followed by 1-15 digits + const phoneRegex = /^\+[1-9]\d{1,14}$/; + return phoneRegex.test(phone); +} + +/** + * Validate recipient has phone number in E.164 format + * + * @param recipient - Recipient to validate + * @returns boolean - true if phone exists and is valid + */ +export function validateWhatsAppRecipient(recipient: NotificationRecipient): boolean { + return !!recipient.phone && isValidPhoneNumber(recipient.phone); +} + +/** + * Error messages for WhatsApp validation + */ +export const WHATSAPP_ERRORS = { + PHONE_REQUIRED: "Recipient phone number is required for WhatsApp", + INVALID_PHONE_FORMAT: (phone: string) => + `Invalid phone number format. Must be E.164 format (e.g., +1234567890). Got: ${phone}`, +} as const;