docs: consolidate exploration & audit reports under docs/ (TEC-3094)
- 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>
This commit is contained in:
287
docs/explorations/from-desktop/NOTIFICATIONS_EXPLORATION.md
Normal file
287
docs/explorations/from-desktop/NOTIFICATIONS_EXPLORATION.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user