@nathapp/nestjs-notification 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/UPGRADE-PLAN-PHASE7-8.md +307 -0
- package/UPGRADE-PLAN-PHASE9.md +361 -0
- package/UPGRADE-PLAN.md +425 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +20 -2
- package/dist/index.js.map +1 -1
- package/dist/interface/index.d.ts +3 -3
- package/dist/interface/message.interface.d.ts +10 -2
- package/dist/interface/payload.inteface.d.ts +51 -0
- package/dist/notification/errors.d.ts +3 -0
- package/dist/notification/errors.js +11 -0
- package/dist/notification/errors.js.map +1 -0
- package/dist/notification/index.d.ts +6 -2
- package/dist/notification/index.js +7 -1
- package/dist/notification/index.js.map +1 -1
- package/dist/notification/notification-provider.factory.d.ts +5 -0
- package/dist/notification/notification-provider.factory.js +33 -0
- package/dist/notification/notification-provider.factory.js.map +1 -0
- package/dist/notification/notification.interceptor.d.ts +18 -0
- package/dist/notification/notification.interceptor.js +5 -0
- package/dist/notification/notification.interceptor.js.map +1 -0
- package/dist/notification/notification.module.d.ts +28 -18
- package/dist/notification/notification.module.js +324 -63
- package/dist/notification/notification.module.js.map +1 -1
- package/dist/notification/notification.service.d.ts +18 -2
- package/dist/notification/notification.service.js +274 -13
- package/dist/notification/notification.service.js.map +1 -1
- package/dist/notification/provider-config.d.ts +17 -0
- package/dist/notification/provider-config.js +3 -0
- package/dist/notification/provider-config.js.map +1 -0
- package/dist/push-notification/push-notification.service.js +2 -1
- package/dist/push-notification/push-notification.service.js.map +1 -1
- package/dist/template/constant.d.ts +1 -0
- package/dist/template/constant.js +2 -1
- package/dist/template/constant.js.map +1 -1
- package/dist/template/engines/string-format.engine.d.ts +6 -0
- package/dist/template/engines/string-format.engine.js +17 -0
- package/dist/template/engines/string-format.engine.js.map +1 -0
- package/dist/template/index.d.ts +4 -2
- package/dist/template/index.js +4 -1
- package/dist/template/index.js.map +1 -1
- package/dist/template/template-engine.d.ts +5 -0
- package/dist/template/template-engine.js +3 -0
- package/dist/template/template-engine.js.map +1 -0
- package/dist/template/template.loader.d.ts +4 -1
- package/dist/template/template.loader.js +22 -14
- package/dist/template/template.loader.js.map +1 -1
- package/dist/whatsapp/constant.d.ts +1 -0
- package/dist/whatsapp/constant.js +5 -0
- package/dist/whatsapp/constant.js.map +1 -0
- package/dist/whatsapp/index.d.ts +2 -0
- package/dist/whatsapp/index.js +19 -0
- package/dist/whatsapp/index.js.map +1 -0
- package/dist/whatsapp/whatsapp.service.d.ts +7 -0
- package/dist/whatsapp/whatsapp.service.js +7 -0
- package/dist/whatsapp/whatsapp.service.js.map +1 -0
- package/package.json +2 -1
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# @nathapp/nestjs-notification — Phase 7 & 8: WhatsApp Channel + Fallback
|
|
2
|
+
|
|
3
|
+
**Branch:** `feat/notification-enterprise-upgrade` (continue from Phase 6)
|
|
4
|
+
**Prerequisite commits:** Phases 1-6 complete, coverage at 81%+
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Phase 7: WhatsApp Channel Support
|
|
9
|
+
|
|
10
|
+
### Problem
|
|
11
|
+
WhatsApp is a major messaging channel (especially in Asia) for OTPs, alerts, and transactional messages. It requires its own channel type because:
|
|
12
|
+
- Pre-approved template requirements (Meta Business API)
|
|
13
|
+
- Rich content support (buttons, media, lists)
|
|
14
|
+
- Session-based delivery model (24h reply window)
|
|
15
|
+
- Different from SMS — cannot reuse `SmsService`
|
|
16
|
+
|
|
17
|
+
### Changes
|
|
18
|
+
|
|
19
|
+
#### 7.1 Create `WhatsAppMessage` interface
|
|
20
|
+
**File:** `lib/interface/message.interface.ts` (UPDATE)
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
export interface WhatsAppMessage extends BaseMessage {
|
|
24
|
+
/** WhatsApp template name (required by Meta Business API) */
|
|
25
|
+
templateName?: string;
|
|
26
|
+
/** Template language code (e.g., 'en', 'zh_CN') */
|
|
27
|
+
languageCode?: string;
|
|
28
|
+
/** Template header parameters */
|
|
29
|
+
headerParams?: string[];
|
|
30
|
+
/** Template body parameters */
|
|
31
|
+
bodyParams?: string[];
|
|
32
|
+
/** Optional media attachment URL */
|
|
33
|
+
mediaUrl?: string;
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
#### 7.2 Create `WhatsAppService` abstract class
|
|
38
|
+
**File:** `lib/whatsapp/whatsapp.service.ts` (NEW)
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { WhatsAppMessage, SendResult } from '../interface';
|
|
42
|
+
|
|
43
|
+
export interface IWhatsAppService {
|
|
44
|
+
send(message: WhatsAppMessage): Promise<SendResult>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export abstract class WhatsAppService implements IWhatsAppService {
|
|
48
|
+
abstract send(message: WhatsAppMessage): Promise<SendResult>;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
#### 7.3 Create WhatsApp index and constants
|
|
53
|
+
**File:** `lib/whatsapp/index.ts` (NEW)
|
|
54
|
+
**File:** `lib/whatsapp/constant.ts` (NEW) — `export const WHATSAPP_SERVICE = 'WHATSAPP_SERVICE';`
|
|
55
|
+
|
|
56
|
+
#### 7.4 Add `WhatsAppNotificationPayload` interface
|
|
57
|
+
**File:** `lib/interface/payload.inteface.ts` (UPDATE)
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
export interface WhatsAppNotificationPayload {
|
|
61
|
+
phoneNumber: string;
|
|
62
|
+
templateCode: string;
|
|
63
|
+
options?: {
|
|
64
|
+
locale?: string;
|
|
65
|
+
fallbackLocale?: string;
|
|
66
|
+
args?: any;
|
|
67
|
+
};
|
|
68
|
+
/** WhatsApp-specific: Meta template name (if different from templateCode) */
|
|
69
|
+
whatsappTemplateName?: string;
|
|
70
|
+
/** WhatsApp-specific: media attachment URL */
|
|
71
|
+
mediaUrl?: string;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
#### 7.5 Update `NotificationChannel` type
|
|
76
|
+
**File:** `lib/interface/payload.inteface.ts` (UPDATE)
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
export type NotificationChannel = 'email' | 'push' | 'sms' | 'whatsapp';
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
#### 7.6 Add WhatsApp to `NotificationService`
|
|
83
|
+
**File:** `lib/notification/notification.service.ts` (UPDATE)
|
|
84
|
+
|
|
85
|
+
- Add `@Optional() @Inject(WhatsAppService) private readonly whatsAppService: WhatsAppService` to constructor
|
|
86
|
+
- Implement `sendWhatsApp(data: WhatsAppNotificationPayload): Promise<SendResult>` method
|
|
87
|
+
- Resolves template via `templateLoader`
|
|
88
|
+
- Calls `whatsAppService.send()` with resolved message
|
|
89
|
+
- Respects `strict` mode (throw if not configured vs return `{ success: false }`)
|
|
90
|
+
- Update `send()` unified method to handle `'whatsapp'` channel
|
|
91
|
+
|
|
92
|
+
#### 7.7 Add WhatsApp options to `NotificationModule`
|
|
93
|
+
**File:** `lib/notification/notification.module.ts` (UPDATE)
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
export interface NotificationModuleOptions {
|
|
97
|
+
// ... existing
|
|
98
|
+
whatsAppService?: Type<WhatsAppService>;
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
- Register `WhatsAppService` provider when `whatsAppService` option is provided
|
|
103
|
+
- Support in both `forRoot` and `forRootAsync`
|
|
104
|
+
|
|
105
|
+
#### 7.8 Update `NotificationContext` for interceptors
|
|
106
|
+
**File:** `lib/notification/notification.interceptor.ts` (UPDATE)
|
|
107
|
+
|
|
108
|
+
The existing `NotificationChannel` type update will automatically make interceptors aware of `'whatsapp'`.
|
|
109
|
+
|
|
110
|
+
#### 7.9 Export new types
|
|
111
|
+
**File:** `lib/whatsapp/index.ts` (NEW)
|
|
112
|
+
**File:** `lib/index.ts` (UPDATE) — export WhatsApp types
|
|
113
|
+
|
|
114
|
+
### Unit Tests
|
|
115
|
+
|
|
116
|
+
**File:** `test/notification/notification-whatsapp.service.spec.ts` (NEW)
|
|
117
|
+
- `sendWhatsApp()` with configured service → resolves template, calls `whatsAppService.send()`
|
|
118
|
+
- `sendWhatsApp()` without service + `strict: true` → throws `NotificationConfigError`
|
|
119
|
+
- `sendWhatsApp()` without service + `strict: false` → returns `{ success: false }`
|
|
120
|
+
- `send({ channels: ['whatsapp'] })` routes to `sendWhatsApp()`
|
|
121
|
+
- `send({ channels: ['email', 'whatsapp'] })` dispatches to both
|
|
122
|
+
- `sendWhatsApp()` passes `whatsappTemplateName` and `mediaUrl` to service
|
|
123
|
+
|
|
124
|
+
### E2E Tests
|
|
125
|
+
|
|
126
|
+
**File:** `test/module/notification-whatsapp.module.e2e.spec.ts` (NEW)
|
|
127
|
+
- Register module with mock `WhatsAppService` → `sendWhatsApp()` works
|
|
128
|
+
- Register module without WhatsApp + `strict: false` → `send({ channels: ['whatsapp'] })` returns `{ success: false }`
|
|
129
|
+
- Full module with email + WhatsApp → unified `send()` dispatches to both
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Phase 8: Channel Fallback Support
|
|
134
|
+
|
|
135
|
+
### Problem
|
|
136
|
+
When a preferred channel fails (e.g., WhatsApp undeliverable), the system should automatically try alternative channels. This must be:
|
|
137
|
+
- Configurable per-notification (not global)
|
|
138
|
+
- Observable via interceptors (log fallback events)
|
|
139
|
+
- Provider-agnostic (WhatsApp via Meta, SMS via Twilio — doesn't matter)
|
|
140
|
+
|
|
141
|
+
### Changes
|
|
142
|
+
|
|
143
|
+
#### 8.1 Add `fallback` to `NotificationPayload`
|
|
144
|
+
**File:** `lib/interface/payload.inteface.ts` (UPDATE)
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
export interface NotificationPayload {
|
|
148
|
+
templateCode: string;
|
|
149
|
+
channels: NotificationChannel[];
|
|
150
|
+
/** Ordered fallback channels. Tried sequentially if ALL primary channels fail. */
|
|
151
|
+
fallback?: NotificationChannel[];
|
|
152
|
+
options?: {
|
|
153
|
+
locale?: string;
|
|
154
|
+
fallbackLocale?: string;
|
|
155
|
+
args?: any;
|
|
156
|
+
};
|
|
157
|
+
// Channel-specific recipient data
|
|
158
|
+
email?: string;
|
|
159
|
+
userId?: string | number;
|
|
160
|
+
deviceToken?: DeviceToken;
|
|
161
|
+
phoneNumber?: string;
|
|
162
|
+
/** WhatsApp-specific fields */
|
|
163
|
+
whatsappTemplateName?: string;
|
|
164
|
+
mediaUrl?: string;
|
|
165
|
+
// Optional
|
|
166
|
+
attachments?: EmailAttachment[];
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
#### 8.2 Add `FallbackResult` to `NotificationResult`
|
|
171
|
+
**File:** `lib/interface/payload.inteface.ts` (UPDATE)
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
export interface NotificationResult {
|
|
175
|
+
results: Partial<Record<NotificationChannel, SendResult>>;
|
|
176
|
+
/** True if primary OR fallback succeeded */
|
|
177
|
+
success: boolean;
|
|
178
|
+
/** Which fallback channels were attempted (empty if primary succeeded) */
|
|
179
|
+
fallbackAttempted?: NotificationChannel[];
|
|
180
|
+
/** Which fallback channel succeeded (undefined if none) */
|
|
181
|
+
fallbackSucceeded?: NotificationChannel;
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
#### 8.3 Update `send()` in `NotificationService` with fallback logic
|
|
186
|
+
**File:** `lib/notification/notification.service.ts` (UPDATE)
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
async send(payload: NotificationPayload): Promise<NotificationResult> {
|
|
190
|
+
// 1. Try all primary channels
|
|
191
|
+
const results: Partial<Record<NotificationChannel, SendResult>> = {};
|
|
192
|
+
for (const channel of payload.channels) {
|
|
193
|
+
results[channel] = await this.sendToChannel(channel, payload);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const primarySuccess = Object.values(results).some(r => r.success);
|
|
197
|
+
|
|
198
|
+
// 2. If ALL primary channels failed AND fallback is defined, try fallbacks
|
|
199
|
+
if (!primarySuccess && payload.fallback?.length) {
|
|
200
|
+
const fallbackAttempted: NotificationChannel[] = [];
|
|
201
|
+
let fallbackSucceeded: NotificationChannel | undefined;
|
|
202
|
+
|
|
203
|
+
for (const fallbackChannel of payload.fallback) {
|
|
204
|
+
fallbackAttempted.push(fallbackChannel);
|
|
205
|
+
const fallbackResult = await this.sendToChannel(fallbackChannel, payload);
|
|
206
|
+
results[fallbackChannel] = fallbackResult;
|
|
207
|
+
|
|
208
|
+
if (fallbackResult.success) {
|
|
209
|
+
fallbackSucceeded = fallbackChannel;
|
|
210
|
+
break; // Stop on first successful fallback
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
results,
|
|
216
|
+
success: !!fallbackSucceeded,
|
|
217
|
+
fallbackAttempted,
|
|
218
|
+
fallbackSucceeded,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
results,
|
|
224
|
+
success: primarySuccess,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Internal: dispatch to a single channel */
|
|
229
|
+
private async sendToChannel(
|
|
230
|
+
channel: NotificationChannel,
|
|
231
|
+
payload: NotificationPayload,
|
|
232
|
+
): Promise<SendResult> {
|
|
233
|
+
switch (channel) {
|
|
234
|
+
case 'email':
|
|
235
|
+
return this.sendEmail({ ... });
|
|
236
|
+
case 'push':
|
|
237
|
+
return this.sendPushNotification({ ... });
|
|
238
|
+
case 'sms':
|
|
239
|
+
return this.sendSms({ ... });
|
|
240
|
+
case 'whatsapp':
|
|
241
|
+
return this.sendWhatsApp({ ... });
|
|
242
|
+
default:
|
|
243
|
+
return { success: false, message: `Unknown channel: ${channel}` };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
#### 8.4 Update interceptors to include fallback context
|
|
249
|
+
**File:** `lib/notification/notification.interceptor.ts` (UPDATE)
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
export interface NotificationContext {
|
|
253
|
+
channel: NotificationChannel;
|
|
254
|
+
templateCode: string;
|
|
255
|
+
recipient: string;
|
|
256
|
+
resolvedMessage?: { subject?: string; content: string };
|
|
257
|
+
/** True if this send is a fallback attempt */
|
|
258
|
+
isFallback?: boolean;
|
|
259
|
+
/** The original channel that failed (when isFallback is true) */
|
|
260
|
+
failedChannel?: NotificationChannel;
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
#### 8.5 Handle unconfigured fallback channels
|
|
265
|
+
**File:** `lib/notification/notification.service.ts` (UPDATE)
|
|
266
|
+
|
|
267
|
+
When a fallback channel's service is not configured:
|
|
268
|
+
- **`strict: true`** → throw `NotificationConfigError` (developer mistake — they specified a fallback they didn't configure)
|
|
269
|
+
- **`strict: false`** → skip that fallback channel silently (log a warning), move to next fallback in the list. If all fallbacks are unconfigured/failed, return `{ success: false }`.
|
|
270
|
+
|
|
271
|
+
The `sendToChannel()` method already handles this via the existing strict/lenient pattern. In lenient mode, an unconfigured channel returns `{ success: false }`, which the fallback loop treats as a failure and moves to the next channel.
|
|
272
|
+
|
|
273
|
+
### Unit Tests
|
|
274
|
+
|
|
275
|
+
**File:** `test/notification/notification-fallback.spec.ts` (NEW)
|
|
276
|
+
- `send()` with `fallback: ['sms']` — primary email succeeds → no fallback attempted
|
|
277
|
+
- `send()` with `channels: ['whatsapp'], fallback: ['sms']` — WhatsApp fails → SMS attempted and succeeds
|
|
278
|
+
- `send()` with `channels: ['whatsapp'], fallback: ['sms']` — both fail → `success: false`, both in `fallbackAttempted`
|
|
279
|
+
- `send()` with `channels: ['whatsapp'], fallback: ['sms', 'email']` — WhatsApp fails, SMS fails, email succeeds → `fallbackSucceeded: 'email'`
|
|
280
|
+
- `send()` with no `fallback` — primary fails → no fallback, `success: false`
|
|
281
|
+
- `send()` with `channels: ['email', 'push']` — email fails, push succeeds → `success: true`, no fallback needed
|
|
282
|
+
- Interceptor `beforeSend` receives `isFallback: true` and `failedChannel` during fallback
|
|
283
|
+
- Interceptor `afterSend` receives fallback context
|
|
284
|
+
- **`send()` with `fallback: ['sms']` but SMS not configured + `strict: true` → throws `NotificationConfigError`**
|
|
285
|
+
- **`send()` with `fallback: ['sms']` but SMS not configured + `strict: false` → skips SMS, returns `{ success: false }`**
|
|
286
|
+
- **`send()` with `fallback: ['sms', 'email']` — SMS not configured (strict: false), email configured and succeeds → `fallbackSucceeded: 'email'`**
|
|
287
|
+
|
|
288
|
+
### E2E Tests
|
|
289
|
+
|
|
290
|
+
**File:** `test/module/notification-fallback.module.e2e.spec.ts` (NEW)
|
|
291
|
+
- Full module with WhatsApp (mock fail) + SMS (mock success) → fallback works end-to-end
|
|
292
|
+
- Full module with email only, `fallback: ['email']` but email succeeds on primary → no fallback
|
|
293
|
+
- Verify interceptor hooks fire with correct `isFallback` flag
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Implementation Order
|
|
298
|
+
|
|
299
|
+
1. **Phase 7** first (WhatsApp channel) — new channel, additive, no breaking changes
|
|
300
|
+
2. **Phase 8** second (Fallback) — builds on Phase 7's `whatsapp` channel for the primary use case
|
|
301
|
+
|
|
302
|
+
## Breaking Changes
|
|
303
|
+
|
|
304
|
+
**None.** Both phases are purely additive:
|
|
305
|
+
- `whatsapp` is a new channel option
|
|
306
|
+
- `fallback` is an optional field on `NotificationPayload`
|
|
307
|
+
- Existing `send()` calls without `fallback` behave identically
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# @nathapp/nestjs-notification — Phase 9: Unified Provider Registry
|
|
2
|
+
|
|
3
|
+
**Branch:** `feat/notification-enterprise-upgrade` (continue from Phase 8)
|
|
4
|
+
**Breaking Change:** Yes — replaces `emailOptions` / `pushNotificationOptions` with unified `providers` array.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Replace the per-channel module registration pattern (`EmailModule.forRoot()`, `PushNotificationModule.register()`) with a single, unified `providers` array on `NotificationModule`. Providers are just classes — no wrapper modules needed.
|
|
11
|
+
|
|
12
|
+
### Before (current)
|
|
13
|
+
```typescript
|
|
14
|
+
NotificationModule.forRoot({
|
|
15
|
+
emailOptions: EmailModule.forRoot({ option: { type: 'smtp', options: {...} } }),
|
|
16
|
+
pushNotificationOptions: PushNotificationModule.register({ providers: [...] }),
|
|
17
|
+
smsService: TwilioSmsProvider,
|
|
18
|
+
whatsAppService: TwilioWhatsAppProvider,
|
|
19
|
+
templateLoader: MyLoader,
|
|
20
|
+
})
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### After (new)
|
|
24
|
+
```typescript
|
|
25
|
+
NotificationModule.forRoot({
|
|
26
|
+
providers: [
|
|
27
|
+
{ channel: 'email', provider: SmtpEmailProvider, options: { url: 'smtp://...', from: 'noreply@app.com' } },
|
|
28
|
+
{ channel: 'push', provider: FcmPushNotificationProvider, options: { projectId: '...' } },
|
|
29
|
+
{ channel: 'sms', provider: TwilioSmsProvider, options: { accountSid: '...', authToken: '...', from: '+1...' } },
|
|
30
|
+
{ channel: 'whatsapp', provider: TwilioWhatsAppProvider, options: { accountSid: '...' } },
|
|
31
|
+
],
|
|
32
|
+
templateLoader: MyLoader,
|
|
33
|
+
strict: true,
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Async with ModuleOptionsFactory
|
|
38
|
+
```typescript
|
|
39
|
+
@Injectable()
|
|
40
|
+
export class AppNotificationOptionsFactory implements NotificationModuleOptionsFactory {
|
|
41
|
+
constructor(private config: ConfigService) {}
|
|
42
|
+
|
|
43
|
+
createOptions(): NotificationModuleOptions {
|
|
44
|
+
return {
|
|
45
|
+
providers: [
|
|
46
|
+
{ channel: 'email', provider: SmtpEmailProvider, options: { url: this.config.get('SMTP_URL'), from: this.config.get('EMAIL_FROM') } },
|
|
47
|
+
],
|
|
48
|
+
templateLoader: MyLoader,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
NotificationModule.forRootAsync({
|
|
54
|
+
imports: [ConfigModule],
|
|
55
|
+
useClass: AppNotificationOptionsFactory,
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Changes
|
|
62
|
+
|
|
63
|
+
### 9.1 Create `ProviderConfig` interface
|
|
64
|
+
**File:** `lib/notification/provider-config.ts` (NEW)
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { Type } from '@nestjs/common';
|
|
68
|
+
import { NotificationChannel } from '../interface';
|
|
69
|
+
import { EmailProvider } from '../email';
|
|
70
|
+
import { PushNotificationProvider } from '../push-notification';
|
|
71
|
+
import { SmsService } from '../sms';
|
|
72
|
+
import { WhatsAppService } from '../whatsapp';
|
|
73
|
+
|
|
74
|
+
export type ChannelProviderMap = {
|
|
75
|
+
email: EmailProvider;
|
|
76
|
+
push: PushNotificationProvider;
|
|
77
|
+
sms: SmsService;
|
|
78
|
+
whatsapp: WhatsAppService;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export interface ProviderConfig<C extends NotificationChannel = NotificationChannel> {
|
|
82
|
+
channel: C;
|
|
83
|
+
provider: Type<ChannelProviderMap[C]>;
|
|
84
|
+
options?: any;
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 9.2 Create `NotificationProviderFactory`
|
|
89
|
+
**File:** `lib/notification/notification-provider.factory.ts` (NEW)
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
import { Logger } from '@nestjs/common';
|
|
93
|
+
import { ProviderConfig, ChannelProviderMap } from './provider-config';
|
|
94
|
+
import { EmailProvider } from '../email';
|
|
95
|
+
import { PushNotificationProvider } from '../push-notification';
|
|
96
|
+
import { SmsService } from '../sms';
|
|
97
|
+
import { WhatsAppService } from '../whatsapp';
|
|
98
|
+
|
|
99
|
+
const CHANNEL_BASE_CLASSES: Record<string, Function> = {
|
|
100
|
+
email: EmailProvider,
|
|
101
|
+
push: PushNotificationProvider,
|
|
102
|
+
sms: SmsService,
|
|
103
|
+
whatsapp: WhatsAppService,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export class NotificationProviderFactory {
|
|
107
|
+
private static readonly logger = new Logger(NotificationProviderFactory.name);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Create a provider instance from config.
|
|
111
|
+
* Validates that the provider class matches the expected base class for its channel.
|
|
112
|
+
* @throws Error if provider doesn't match its channel
|
|
113
|
+
*/
|
|
114
|
+
static create<C extends keyof ChannelProviderMap>(
|
|
115
|
+
config: ProviderConfig<C>,
|
|
116
|
+
): ChannelProviderMap[C] {
|
|
117
|
+
const { channel, provider, options } = config;
|
|
118
|
+
const baseClass = CHANNEL_BASE_CLASSES[channel];
|
|
119
|
+
|
|
120
|
+
if (!baseClass) {
|
|
121
|
+
throw new Error(`Unknown notification channel: ${channel}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Validate: check prototype chain
|
|
125
|
+
const instance = new provider(options);
|
|
126
|
+
|
|
127
|
+
if (!(instance instanceof baseClass)) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`${provider.name} is not a valid ${channel} provider. ` +
|
|
130
|
+
`Expected ${baseClass.name} subclass.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.logger.log(`Created ${channel} provider: ${provider.name}`);
|
|
135
|
+
return instance as ChannelProviderMap[C];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 9.3 Rewrite `NotificationModule` options interfaces
|
|
141
|
+
**File:** `lib/notification/notification.module.ts` (REWRITE)
|
|
142
|
+
|
|
143
|
+
Replace the old `NotificationModuleOptions` and `NotificationModuleAsyncOptions` with:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
export interface NotificationModuleOptions {
|
|
147
|
+
/** Unified provider registration */
|
|
148
|
+
providers?: ProviderConfig[];
|
|
149
|
+
/** Template loader class */
|
|
150
|
+
templateLoader: Type<TemplateLoader> | ProviderAsyncOptions<TemplateLoader>;
|
|
151
|
+
/** Template engine class (default: StringFormatEngine) */
|
|
152
|
+
templateEngine?: Type<TemplateEngine>;
|
|
153
|
+
/** Template service override */
|
|
154
|
+
templateService?: Type<TemplateService>;
|
|
155
|
+
/** Notification service override */
|
|
156
|
+
notificationService?: Type<NotificationService>;
|
|
157
|
+
/** Interceptors */
|
|
158
|
+
interceptors?: Type<NotificationInterceptor>[];
|
|
159
|
+
/** Strict mode (default: true) */
|
|
160
|
+
strict?: boolean;
|
|
161
|
+
/** Push notification provider manager override */
|
|
162
|
+
providerManager?: Type<ProviderManager>;
|
|
163
|
+
/** Push notification user token store override */
|
|
164
|
+
userTokenStore?: Type<UserTokenStore>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface NotificationModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
|
|
168
|
+
/** Use a factory class to create options */
|
|
169
|
+
useClass?: Type<NotificationModuleOptionsFactory>;
|
|
170
|
+
/** Reuse an existing factory */
|
|
171
|
+
useExisting?: Type<NotificationModuleOptionsFactory>;
|
|
172
|
+
/** Inline factory function */
|
|
173
|
+
useFactory?: (...args: any[]) => NotificationModuleOptions | Promise<NotificationModuleOptions>;
|
|
174
|
+
inject?: any[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface NotificationModuleOptionsFactory {
|
|
178
|
+
createOptions(): NotificationModuleOptions | Promise<NotificationModuleOptions>;
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### 9.4 Rewrite `NotificationModule.forRoot()`
|
|
183
|
+
**File:** `lib/notification/notification.module.ts` (REWRITE)
|
|
184
|
+
|
|
185
|
+
The new `forRoot` should:
|
|
186
|
+
1. Iterate `options.providers` array
|
|
187
|
+
2. Use `NotificationProviderFactory.create()` for each
|
|
188
|
+
3. Group by channel type
|
|
189
|
+
4. Register each as the appropriate injection token:
|
|
190
|
+
- `email` → `EMAIL_PROVIDER` token + create `EmailService` wrapping it
|
|
191
|
+
- `push` → `PUSH_NOTIFICATION_PROVIDERS` token + create `PushNotificationService`
|
|
192
|
+
- `sms` → `SmsService` token
|
|
193
|
+
- `whatsapp` → `WhatsAppService` token
|
|
194
|
+
5. Register template engine, template loader, template service, notification service, interceptors (same as current)
|
|
195
|
+
6. Store `strict` option in `NOTIFICATION_OPTIONS`
|
|
196
|
+
|
|
197
|
+
**Key implementation detail:** When a channel has no provider registered, do NOT create its service. `NotificationService` already handles missing services via `@Optional()` inject + strict mode.
|
|
198
|
+
|
|
199
|
+
### 9.5 Rewrite `NotificationModule.forRootAsync()`
|
|
200
|
+
**File:** `lib/notification/notification.module.ts` (REWRITE)
|
|
201
|
+
|
|
202
|
+
Support all three async patterns:
|
|
203
|
+
|
|
204
|
+
**`useClass`:**
|
|
205
|
+
```typescript
|
|
206
|
+
NotificationModule.forRootAsync({
|
|
207
|
+
imports: [ConfigModule],
|
|
208
|
+
useClass: AppNotificationOptionsFactory,
|
|
209
|
+
})
|
|
210
|
+
```
|
|
211
|
+
- NestJS creates `AppNotificationOptionsFactory`, injects its dependencies
|
|
212
|
+
- Calls `factory.createOptions()` to get `NotificationModuleOptions`
|
|
213
|
+
- Then same provider registration as `forRoot`
|
|
214
|
+
|
|
215
|
+
**`useExisting`:**
|
|
216
|
+
```typescript
|
|
217
|
+
NotificationModule.forRootAsync({
|
|
218
|
+
useExisting: AppNotificationOptionsFactory,
|
|
219
|
+
})
|
|
220
|
+
```
|
|
221
|
+
- Reuses an already-registered factory
|
|
222
|
+
|
|
223
|
+
**`useFactory`:**
|
|
224
|
+
```typescript
|
|
225
|
+
NotificationModule.forRootAsync({
|
|
226
|
+
imports: [ConfigModule],
|
|
227
|
+
useFactory: (config: ConfigService) => ({
|
|
228
|
+
providers: [
|
|
229
|
+
{ channel: 'email', provider: SmtpEmailProvider, options: { url: config.get('SMTP_URL') } },
|
|
230
|
+
],
|
|
231
|
+
templateLoader: MyLoader,
|
|
232
|
+
}),
|
|
233
|
+
inject: [ConfigService],
|
|
234
|
+
})
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### 9.6 Keep `EmailModule` and `PushNotificationModule` as internal
|
|
238
|
+
**File:** `lib/email/email.module.ts`, `lib/push-notification/push-notification.module.ts`
|
|
239
|
+
|
|
240
|
+
Do NOT delete these modules. They still work internally. But they are no longer the primary public API. The public API is `NotificationModule.forRoot({ providers: [...] })`.
|
|
241
|
+
|
|
242
|
+
Remove `EmailModule` and `PushNotificationModule` from the main `index.ts` exports. Keep them accessible for advanced use cases via deep imports, but they're no longer the recommended approach.
|
|
243
|
+
|
|
244
|
+
### 9.7 Update `NotificationService` constructor
|
|
245
|
+
**File:** `lib/notification/notification.service.ts` (UPDATE)
|
|
246
|
+
|
|
247
|
+
The constructor already uses `@Optional()` for all services. No changes needed here — the module registration changes handle which services are available.
|
|
248
|
+
|
|
249
|
+
### 9.8 Export new types
|
|
250
|
+
**File:** `lib/notification/index.ts` (UPDATE)
|
|
251
|
+
**File:** `lib/index.ts` (UPDATE)
|
|
252
|
+
|
|
253
|
+
Export: `ProviderConfig`, `ChannelProviderMap`, `NotificationProviderFactory`, `NotificationModuleOptionsFactory`
|
|
254
|
+
|
|
255
|
+
Remove from top-level exports: `EmailModule`, `EmailModuleOptions`, `EmailModuleAsyncOptions`, `PushNotificationModule`, `PushNotificationModuleOptions`, `PushNotificationModuleAsyncOptions`
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## Unit Tests
|
|
260
|
+
|
|
261
|
+
**File:** `test/notification/notification-provider.factory.spec.ts` (NEW)
|
|
262
|
+
- `create()` with `SmtpEmailProvider` + channel `email` → returns instance
|
|
263
|
+
- `create()` with `FcmPushNotificationProvider` + channel `push` → returns instance
|
|
264
|
+
- `create()` with options → passes options to constructor
|
|
265
|
+
- `create()` with mismatched provider/channel → throws descriptive error (e.g., `TwilioSmsProvider` on `email` channel)
|
|
266
|
+
- `create()` with unknown channel → throws `Unknown notification channel` error
|
|
267
|
+
- `create()` without options → creates provider with undefined options (no crash)
|
|
268
|
+
|
|
269
|
+
**File:** `test/notification/notification-module-unified.spec.ts` (NEW)
|
|
270
|
+
- `forRoot()` with single email provider → `EmailService` is available, `NotificationService` can send email
|
|
271
|
+
- `forRoot()` with multiple channels → all services registered
|
|
272
|
+
- `forRoot()` with no providers → no services available, strict mode throws on send
|
|
273
|
+
- `forRoot()` with `strict: false` + no providers → `send()` returns `{ success: false }`
|
|
274
|
+
- `forRoot()` with custom `templateEngine` → engine is used
|
|
275
|
+
- `forRoot()` with `interceptors` → interceptors are called
|
|
276
|
+
|
|
277
|
+
**File:** `test/notification/notification-module-async.spec.ts` (NEW)
|
|
278
|
+
- `forRootAsync()` with `useFactory` → providers created from factory result
|
|
279
|
+
- `forRootAsync()` with `useClass` (NotificationModuleOptionsFactory) → factory `createOptions()` called, providers registered
|
|
280
|
+
- `forRootAsync()` with `useExisting` → reuses existing factory
|
|
281
|
+
- `forRootAsync()` with `inject: [ConfigService]` → config values passed to factory
|
|
282
|
+
|
|
283
|
+
## E2E Tests
|
|
284
|
+
|
|
285
|
+
**File:** `test/module/notification-unified-providers.module.e2e.spec.ts` (NEW)
|
|
286
|
+
- Full module with `providers: [{ channel: 'email', provider: SmtpEmailProvider, options: {...} }]` → email sending works
|
|
287
|
+
- Full module with email + sms providers → both channels work via unified `send()`
|
|
288
|
+
- Module with mismatched provider → bootstrap throws
|
|
289
|
+
- `forRootAsync` with `useClass` factory → full e2e send works
|
|
290
|
+
|
|
291
|
+
**File:** `test/module/notification-options-factory.module.e2e.spec.ts` (NEW)
|
|
292
|
+
- `useClass: TestOptionsFactory` → factory is instantiated, `createOptions()` called
|
|
293
|
+
- Factory receives injected dependencies (e.g., a mock config service)
|
|
294
|
+
- Options from factory are used to create providers correctly
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## Migration Guide (for consumers)
|
|
299
|
+
|
|
300
|
+
### Before
|
|
301
|
+
```typescript
|
|
302
|
+
NotificationModule.forRoot({
|
|
303
|
+
emailOptions: { option: { type: 'smtp', options: { url: '...', from: '...' } } },
|
|
304
|
+
pushNotificationOptions: { providers: [{ name: 'fcm', ... }] },
|
|
305
|
+
templateLoader: MyLoader,
|
|
306
|
+
})
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### After
|
|
310
|
+
```typescript
|
|
311
|
+
NotificationModule.forRoot({
|
|
312
|
+
providers: [
|
|
313
|
+
{ channel: 'email', provider: SmtpEmailProvider, options: { url: '...', from: '...' } },
|
|
314
|
+
{ channel: 'push', provider: FcmPushNotificationProvider, options: { projectId: '...' } },
|
|
315
|
+
],
|
|
316
|
+
templateLoader: MyLoader,
|
|
317
|
+
})
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Async Before
|
|
321
|
+
```typescript
|
|
322
|
+
NotificationModule.forRootAsync({
|
|
323
|
+
emailOptions: {
|
|
324
|
+
useFactory: (config) => ({ option: { type: 'smtp', options: { url: config.get('SMTP') } } }),
|
|
325
|
+
inject: [ConfigService],
|
|
326
|
+
},
|
|
327
|
+
templateLoader: MyLoader,
|
|
328
|
+
})
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Async After
|
|
332
|
+
```typescript
|
|
333
|
+
NotificationModule.forRootAsync({
|
|
334
|
+
imports: [ConfigModule],
|
|
335
|
+
useClass: AppNotificationOptionsFactory,
|
|
336
|
+
})
|
|
337
|
+
// or
|
|
338
|
+
NotificationModule.forRootAsync({
|
|
339
|
+
imports: [ConfigModule],
|
|
340
|
+
useFactory: (config: ConfigService) => ({
|
|
341
|
+
providers: [
|
|
342
|
+
{ channel: 'email', provider: SmtpEmailProvider, options: { url: config.get('SMTP') } },
|
|
343
|
+
],
|
|
344
|
+
templateLoader: MyLoader,
|
|
345
|
+
}),
|
|
346
|
+
inject: [ConfigService],
|
|
347
|
+
})
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
## Breaking Changes
|
|
353
|
+
|
|
354
|
+
1. `NotificationModuleOptions.emailOptions` → removed (use `providers` array)
|
|
355
|
+
2. `NotificationModuleOptions.pushNotificationOptions` → removed (use `providers` array)
|
|
356
|
+
3. `NotificationModuleOptions.smsService` → removed (use `providers` array)
|
|
357
|
+
4. `NotificationModuleOptions.whatsAppService` → removed (use `providers` array)
|
|
358
|
+
5. `EmailModule`, `PushNotificationModule` no longer exported from main index (still accessible via deep import)
|
|
359
|
+
6. `EmailModuleOptions`, `PushNotificationModuleOptions` etc. no longer exported from main index
|
|
360
|
+
|
|
361
|
+
All changes are within the **same v2.2.0 release** since no prior release has shipped these new features yet.
|