Files
goodgo-platform/docs/explorations/from-desktop/NOTIFICATIONS_EXPLORATION.md
Ho Ngoc Hai 08b96f9c2d 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>
2026-04-21 16:29:24 +07:00

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