- Move 8 stray .md (+5 .txt) from ~/Desktop into docs/explorations/from-desktop/ - Reorganize 27 .md/.txt at workspace root: - audit reports -> docs/audits/ - exploration reports -> docs/explorations/ - design system -> docs/design-system/ - Keep only README/CHANGELOG/CONTRIBUTING/CLAUDE at repo root - Refresh docs/README.md as canonical index with links to all groups - Note: pre-existing docs/audits/AUDIT_INDEX.md and AUDIT_SUMMARY.md were overwritten by the newer root-level versions during the move Co-Authored-By: Paperclip <noreply@paperclip.ing>
288 lines
7.9 KiB
Markdown
288 lines
7.9 KiB
Markdown
# Notifications Module - Complete Exploration
|
|
|
|
## 📊 Summary
|
|
- **Total Files**: 67 TypeScript files
|
|
- **Architecture**: DDD + CQRS
|
|
- **Channels**: EMAIL, SMS, PUSH (FCM), ZALO_OA
|
|
- **Event Listeners**: 18 domain event listeners
|
|
- **Current SMS Provider**: Stringee (fully implemented)
|
|
|
|
---
|
|
|
|
## 🎯 Main Interface: NotificationChannelPort
|
|
|
|
**Location**: `domain/ports/notification-channel.port.ts`
|
|
|
|
```typescript
|
|
export interface SendChannelMessageDto {
|
|
recipient: string; // Email/phone/token
|
|
subject: string; // For EMAIL
|
|
body: string; // HTML content
|
|
templateKey: string; // e.g., 'user.registered'
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface SendChannelMessageResult {
|
|
messageId: string;
|
|
}
|
|
|
|
export interface NotificationChannelPort {
|
|
readonly channel: NotificationChannel; // 'EMAIL' | 'SMS' | 'PUSH' | 'ZALO_OA'
|
|
readonly isAvailable: boolean;
|
|
send(dto: SendChannelMessageDto): Promise<SendChannelMessageResult>;
|
|
}
|
|
|
|
export const SMS_NOTIFICATION_CHANNEL = Symbol('SMS_NOTIFICATION_CHANNEL');
|
|
```
|
|
|
|
---
|
|
|
|
## 📡 SMS Rate Limiting
|
|
|
|
**Location**: `infrastructure/services/sms-rate-limiter.service.ts`
|
|
|
|
- **Technology**: Redis + Lua script (atomic, sliding window)
|
|
- **Buckets**:
|
|
- `otp`: 5/min, 10/hour (strict)
|
|
- `transactional`: 20/min, 100/hour (lenient)
|
|
- **Graceful degradation**: Redis error → allow request
|
|
|
|
---
|
|
|
|
## 🔧 Stringee SMS Service (Reference Implementation)
|
|
|
|
**Location**: `infrastructure/services/stringee-sms.service.ts`
|
|
|
|
### Constructor Dependencies
|
|
- `LoggerService`
|
|
- `SmsRateLimiterService`
|
|
|
|
### Environment Variables
|
|
- `STRINGEE_API_KEY` (required)
|
|
- `STRINGEE_BRANDNAME` (optional, default: "GoodGo")
|
|
|
|
### Key Methods
|
|
1. `onModuleInit()` - Read env, validate, initialize
|
|
2. `get isAvailable()` - Check if initialized
|
|
3. `async send(dto)` - NotificationChannelPort implementation
|
|
4. `async sendOTP(dto)` - Specific OTP handling
|
|
5. `async dispatch(dto)` - Main workflow
|
|
6. `async enforceRateLimit()` - Check per-minute AND hourly
|
|
7. `async sendWithRetry()` - 3 attempts with exponential backoff
|
|
8. `normalizePhone()` - +84xxx format conversion
|
|
9. `stripHtml()` - Remove HTML tags
|
|
|
|
### Workflow
|
|
1. Rate limit check (per-minute + hourly)
|
|
2. HTML stripping
|
|
3. Phone normalization (+84, 84, 0 formats)
|
|
4. Retry logic (1s → 2s → 4s)
|
|
5. API call to https://api.stringee.com/v1/sms
|
|
6. Return messageId
|
|
|
|
---
|
|
|
|
## 🏗️ DI Configuration
|
|
|
|
**Location**: `notifications.module.ts`
|
|
|
|
```typescript
|
|
@Module({
|
|
providers: [
|
|
// Repositories
|
|
{ provide: NOTIFICATION_REPOSITORY, useClass: PrismaNotificationRepository },
|
|
{ provide: NOTIFICATION_PREFERENCE_REPOSITORY, useClass: PrismaNotificationPreferenceRepository },
|
|
|
|
// Services
|
|
EmailService,
|
|
FcmService,
|
|
SmsRateLimiterService,
|
|
StringeeSmsService,
|
|
{ provide: SMS_NOTIFICATION_CHANNEL, useExisting: StringeeSmsService },
|
|
ZaloOaService,
|
|
TemplateService,
|
|
|
|
// Others
|
|
NotificationsGateway,
|
|
SendNotificationHandler,
|
|
...EventListeners,
|
|
],
|
|
exports: [
|
|
EmailService,
|
|
FcmService,
|
|
SmsRateLimiterService,
|
|
StringeeSmsService,
|
|
SMS_NOTIFICATION_CHANNEL,
|
|
ZaloOaService,
|
|
TemplateService,
|
|
NotificationsGateway,
|
|
],
|
|
})
|
|
export class NotificationsModule {}
|
|
```
|
|
|
|
---
|
|
|
|
## 🔄 Message Flow
|
|
|
|
```
|
|
Domain Event (e.g., user.registered)
|
|
↓
|
|
@OnEvent('user.registered') Listener
|
|
↓
|
|
CommandBus.execute(SendNotificationCommand)
|
|
↓
|
|
SendNotificationHandler
|
|
├─ Check user preference (enabled?)
|
|
├─ Render template
|
|
├─ Persist notification log
|
|
├─ Dispatch to channel:
|
|
│ ├─ EMAIL → EmailService.send()
|
|
│ ├─ SMS → StringeeSmsService.send() [with rate limit + retry]
|
|
│ ├─ PUSH → FcmService.send()
|
|
│ └─ ZALO → ZaloOaService.send()
|
|
├─ Update status (SENT/FAILED)
|
|
└─ Publish NotificationSentEvent
|
|
```
|
|
|
|
---
|
|
|
|
## 💡 Key Patterns
|
|
|
|
### Pattern 1: OnModuleInit + Lazy Initialization
|
|
- Read env vars in `onModuleInit()`
|
|
- Set `initialized = true` only if valid
|
|
- Check `isAvailable` getter before operations
|
|
|
|
### Pattern 2: DI Token Binding
|
|
```typescript
|
|
export const MY_TOKEN = Symbol('MY_TOKEN');
|
|
|
|
providers: [
|
|
MyService,
|
|
{ provide: MY_TOKEN, useExisting: MyService }
|
|
]
|
|
|
|
// Inject
|
|
@Inject(MY_TOKEN) myService: SomeInterface
|
|
```
|
|
|
|
### Pattern 3: Rate Limiting
|
|
- Always call `rateLimiter.check(phone, bucket)` first
|
|
- Support OTP vs transactional buckets
|
|
- Check BOTH per-minute and hourly limits
|
|
- Throw `DomainException` on violation
|
|
|
|
### Pattern 4: Error Handling
|
|
```typescript
|
|
throw new DomainException(
|
|
ErrorCode.TOO_MANY_REQUESTS,
|
|
`SMS limit exceeded. Retry after ${retryAfterSeconds}s.`,
|
|
HttpStatus.TOO_MANY_REQUESTS,
|
|
{ bucket, retryAfterSeconds }
|
|
);
|
|
```
|
|
|
|
### Pattern 5: Retry Logic
|
|
- MAX_RETRIES = 3
|
|
- Exponential backoff: 1s → 2s → 4s
|
|
- Log each attempt, fail silently on final retry
|
|
|
|
---
|
|
|
|
## 📁 Directory Structure
|
|
|
|
```
|
|
notifications/
|
|
├── domain/
|
|
│ ├── ports/
|
|
│ │ └── notification-channel.port.ts ⭐ Main interface
|
|
│ ├── repositories/ ← DI Tokens
|
|
│ ├── value-objects/
|
|
│ │ └── notification-channel.vo.ts ← Channel enum
|
|
│ ├── entities/
|
|
│ └── events/
|
|
│ └── notification-sent.event.ts
|
|
├── infrastructure/
|
|
│ ├── services/
|
|
│ │ ├── stringee-sms.service.ts ⭐ SMS provider example
|
|
│ │ ├── sms-rate-limiter.service.ts ← Rate limiting
|
|
│ │ ├── email.service.ts
|
|
│ │ ├── fcm.service.ts
|
|
│ │ └── zalo-oa.service.ts
|
|
│ └── repositories/
|
|
│ ├── prisma-notification.repository.ts
|
|
│ └── prisma-notification-preference.repository.ts
|
|
├── application/
|
|
│ ├── commands/
|
|
│ │ └── send-notification/
|
|
│ │ ├── send-notification.command.ts
|
|
│ │ └── send-notification.handler.ts ⭐ Command dispatcher
|
|
│ └── listeners/ ← 18 event listeners
|
|
├── presentation/
|
|
│ ├── controllers/
|
|
│ └── gateways/
|
|
│ └── notifications.gateway.ts ← WebSocket
|
|
├── notifications.module.ts ⭐ DI setup
|
|
└── index.ts
|
|
```
|
|
|
|
---
|
|
|
|
## 🛠️ Implementation Checklist (for new SMS provider)
|
|
|
|
- [ ] Create `infrastructure/services/{provider}-sms.service.ts`
|
|
- [ ] Implement `NotificationChannelPort` interface
|
|
- [ ] Implement `OnModuleInit` for env var reading
|
|
- [ ] Inject `SmsRateLimiterService`
|
|
- [ ] Implement rate limit enforcement
|
|
- [ ] Implement phone normalization
|
|
- [ ] Strip HTML from body
|
|
- [ ] Implement retry logic (3 attempts, exponential backoff)
|
|
- [ ] Register in `notifications.module.ts`
|
|
- [ ] Integrate with `SendNotificationHandler`
|
|
- [ ] Write unit tests
|
|
|
|
---
|
|
|
|
## 📊 SMS Rate Limits
|
|
|
|
| Bucket | Per-Minute | Per-Hour | Use Case |
|
|
|--------|-----------|----------|----------|
|
|
| `otp` | 5 | 10 | OTP codes, verification |
|
|
| `transactional` | 20 | 100 | Regular notifications |
|
|
|
|
**OTP Templates** (use strict limits):
|
|
- user.phone_change_otp
|
|
- auth.login_otp
|
|
- auth.kyc_otp
|
|
- auth.phone_verify_otp
|
|
|
|
---
|
|
|
|
## 🔗 Quick Reference
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `domain/ports/notification-channel.port.ts` | Interface + DI symbols |
|
|
| `infrastructure/services/stringee-sms.service.ts` | SMS provider example |
|
|
| `infrastructure/services/sms-rate-limiter.service.ts` | Rate limiting |
|
|
| `application/commands/send-notification/send-notification.handler.ts` | Command handler |
|
|
| `notifications.module.ts` | DI configuration |
|
|
| `application/listeners/user-registered.listener.ts` | Event listener example |
|
|
|
|
---
|
|
|
|
## ✅ Everything You Need
|
|
|
|
- [x] Full directory structure
|
|
- [x] Main interface (NotificationChannelPort)
|
|
- [x] Existing SMS implementation (Stringee - complete reference)
|
|
- [x] Rate limiting system (Redis-based)
|
|
- [x] DI configuration patterns
|
|
- [x] Message flow understanding
|
|
- [x] Handler integration patterns
|
|
- [x] Error handling patterns
|
|
- [x] Testing patterns
|
|
|