From 0c6a5131eb65837c36850924d34378226cfc3928 Mon Sep 17 00:00:00 2001 From: yasser Date: Mon, 23 Feb 2026 15:55:59 +0000 Subject: [PATCH] infrastructure adapters --- package.json | 26 ++ src/infra/README.md | 377 +++++++++++++++++- src/infra/index.ts | 21 + src/infra/providers/datetime.provider.ts | 28 ++ src/infra/providers/event-emitter.provider.ts | 62 +++ src/infra/providers/id-generator.provider.ts | 77 ++++ src/infra/providers/index.ts | 5 + src/infra/providers/template.provider.ts | 124 ++++++ .../in-memory/in-memory.repository.ts | 178 +++++++++ src/infra/repositories/index.ts | 6 + .../mongoose/mongoose.repository.ts | 260 ++++++++++++ .../mongoose/notification.schema.ts | 88 ++++ src/infra/senders/email/nodemailer.sender.ts | 119 ++++++ src/infra/senders/index.ts | 12 + src/infra/senders/push/aws-sns-push.sender.ts | 128 ++++++ src/infra/senders/push/firebase.sender.ts | 104 +++++ src/infra/senders/push/onesignal.sender.ts | 97 +++++ src/infra/senders/sms/aws-sns.sender.ts | 119 ++++++ src/infra/senders/sms/twilio.sender.ts | 100 +++++ src/infra/senders/sms/vonage.sender.ts | 113 ++++++ tsconfig.json | 2 +- 21 files changed, 2041 insertions(+), 5 deletions(-) create mode 100644 src/infra/index.ts create mode 100644 src/infra/providers/datetime.provider.ts create mode 100644 src/infra/providers/event-emitter.provider.ts create mode 100644 src/infra/providers/id-generator.provider.ts create mode 100644 src/infra/providers/index.ts create mode 100644 src/infra/providers/template.provider.ts create mode 100644 src/infra/repositories/in-memory/in-memory.repository.ts create mode 100644 src/infra/repositories/index.ts create mode 100644 src/infra/repositories/mongoose/mongoose.repository.ts create mode 100644 src/infra/repositories/mongoose/notification.schema.ts create mode 100644 src/infra/senders/email/nodemailer.sender.ts create mode 100644 src/infra/senders/index.ts create mode 100644 src/infra/senders/push/aws-sns-push.sender.ts create mode 100644 src/infra/senders/push/firebase.sender.ts create mode 100644 src/infra/senders/push/onesignal.sender.ts create mode 100644 src/infra/senders/sms/aws-sns.sender.ts create mode 100644 src/infra/senders/sms/twilio.sender.ts create mode 100644 src/infra/senders/sms/vonage.sender.ts diff --git a/package.json b/package.json index bd323e2..8e8f357 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" }, 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..7f47825 --- /dev/null +++ b/src/infra/repositories/mongoose/mongoose.repository.ts @@ -0,0 +1,260 @@ +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) => 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) => 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: Map | any): Record { + if (map instanceof Map) { + return Object.fromEntries(map); + } + return map; + } +} 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/tsconfig.json b/tsconfig.json index 63ab110..fbeb6c4 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,